about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.dir-locals.el4
-rw-r--r--.envrc4
-rw-r--r--.gitignore9
-rw-r--r--.golangci.yaml2
-rw-r--r--.ko.yaml11
-rw-r--r--ci.nix18
-rw-r--r--cmd/build/main.go31
-rw-r--r--cmd/cspgenerator/cspgenerator.go5
-rw-r--r--cmd/server/main.go63
-rw-r--r--config.toml29
-rw-r--r--content/index.md (renamed from content/_index.md)0
-rw-r--r--content/post/searchix.md33
-rw-r--r--content/post/when-tailscale-magicdns-isn't.md200
-rw-r--r--default.nix31
-rw-r--r--flake.lock193
-rw-r--r--flake.nix85
-rw-r--r--fly.toml75
-rw-r--r--go.mod114
-rw-r--r--go.sum308
-rw-r--r--internal/atom/atom.go42
-rw-r--r--internal/builder/builder.go277
-rw-r--r--internal/builder/files.go120
-rw-r--r--internal/builder/hasher.go13
-rw-r--r--internal/builder/sitemap.go29
-rw-r--r--internal/builder/template.go437
-rw-r--r--internal/config/config.go18
-rw-r--r--internal/config/cspgenerator.go2
-rw-r--r--internal/content/posts.go (renamed from internal/builder/posts.go)29
-rw-r--r--internal/http/error.go7
-rw-r--r--internal/log/log.go48
-rw-r--r--internal/server/dev.go74
-rw-r--r--internal/server/logging.go16
-rw-r--r--internal/server/mime.go9
-rw-r--r--internal/server/server.go234
-rw-r--r--internal/server/tcp.go14
-rw-r--r--internal/server/tls.go185
-rw-r--r--internal/sitemap/sitemap.go36
-rw-r--r--internal/vcs/repository.go123
-rw-r--r--internal/website/filemap.go68
-rw-r--r--internal/website/mux.go76
-rwxr-xr-xjustfile83
-rw-r--r--modd.conf5
-rw-r--r--netlify.toml33
-rw-r--r--nix/default.nix101
-rw-r--r--nix/gomod2nix.toml121
-rw-r--r--npins/default.nix80
-rw-r--r--npins/sources.json29
-rw-r--r--shell.nix28
-rw-r--r--templates/404.html37
-rw-r--r--templates/atom.xml48
-rw-r--r--templates/count.html8
-rw-r--r--templates/dev.go9
-rw-r--r--templates/dev.html8
-rw-r--r--templates/embed.go8
-rw-r--r--templates/error.templ17
-rw-r--r--templates/feed-styles.xsl4
-rw-r--r--templates/feed.xml24
-rw-r--r--templates/homepage.html63
-rw-r--r--templates/homepage.templ42
-rw-r--r--templates/list.html52
-rw-r--r--templates/list.templ51
-rw-r--r--templates/page.templ115
-rw-r--r--templates/post.html78
-rw-r--r--templates/post.templ56
-rw-r--r--templates/style.css9
-rw-r--r--templates/tags.html42
-rw-r--r--templates/tags.templ23
-rw-r--r--vercel.json59
68 files changed, 2363 insertions, 1942 deletions
diff --git a/.dir-locals.el b/.dir-locals.el
new file mode 100644
index 0000000..f943f69
--- /dev/null
+++ b/.dir-locals.el
@@ -0,0 +1,4 @@
+;;; Directory Local Variables            -*- no-byte-compile: t -*-
+;;; For more information see (info "(emacs) Directory Variables")
+
+((go-ts-mode . ((apheleia-formatter . golines)))))
diff --git a/.envrc b/.envrc
index 51aa811..a506aa5 100644
--- a/.envrc
+++ b/.envrc
@@ -3,5 +3,7 @@ if type -P lorri &>/dev/null; then
   eval "$(lorri direnv)"
 else
   echo 'while direnv evaluated .envrc, could not find the command "lorri" [https://github.com/nix-community/lorri]'
-  use flake
+  use nix
 fi
+
+dotenv
diff --git a/.gitignore b/.gitignore
index 88424a9..130d516 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,9 +25,10 @@ vendor/
 go.work
 
 # End of https://www.toptal.com/developers/gitignore/api/go
-/netlify/.netlify/
-/website/
+/public/
 /.pre-commit-config.yaml
 /result
-.vercel
-/.netlify/
+
+*_templ.go
+*_templ.txt
+/.env
diff --git a/.golangci.yaml b/.golangci.yaml
index e6c1096..1a9f243 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -10,7 +10,6 @@ linters:
     - grouper
     - lll
     - nilerr
-    - nilnil
     - nlreturn
     - noctx
     - nosprintfhostport
@@ -20,4 +19,3 @@ linters:
     - revive
     - sloglint
     - unconvert
-    - wrapcheck
diff --git a/.ko.yaml b/.ko.yaml
new file mode 100644
index 0000000..15ce912
--- /dev/null
+++ b/.ko.yaml
@@ -0,0 +1,11 @@
+defaultPlatforms:
+  - linux/amd64
+builds:
+  - id: alanpearce-eu
+    main: cmd/server
+    flags:
+      - -tags
+      - embed
+    ldflags:
+      - -X go.alanpearce.eu/website/internal/server.CommitSHA={{ .Git.FullCommit }}
+      - -X go.alanpearce.eu/website/internal/server.ShortSHA={{ .Git.ShortCommit }}
diff --git a/ci.nix b/ci.nix
new file mode 100644
index 0000000..1fb3e9a
--- /dev/null
+++ b/ci.nix
@@ -0,0 +1,18 @@
+{ pkgs ? (
+    let
+      sources = import ./npins;
+    in
+    import sources.nixpkgs { }
+  )
+}:
+pkgs.mkShell {
+  packages = with pkgs; [
+    go_1_23
+    templ
+    hyperlink
+    just
+
+    ko
+    flyctl
+  ];
+}
diff --git a/cmd/build/main.go b/cmd/build/main.go
index 0b1cc46..84de2dc 100644
--- a/cmd/build/main.go
+++ b/cmd/build/main.go
@@ -2,27 +2,26 @@ package main
 
 import (
 	"fmt"
-	"io/fs"
 	"os"
 
-	"website/internal/builder"
-	"website/internal/log"
+	"go.alanpearce.eu/website/internal/builder"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/x/log"
 
-	"github.com/BurntSushi/toml"
 	"github.com/ardanlabs/conf/v3"
-	"github.com/pkg/errors"
+	"gitlab.com/tozd/go/errors"
 )
 
 func main() {
-	ioConfig := builder.IOConfig{}
-	if help, err := conf.Parse("", &ioConfig); err != nil {
+	ioConfig := &builder.IOConfig{}
+	if help, err := conf.Parse("", ioConfig); err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
 			fmt.Println(help)
 			os.Exit(1)
 		}
 		panic("error parsing configuration: " + err.Error())
 	}
-	log.Configure(!ioConfig.Development)
+	log := log.Configure(!ioConfig.Development)
 
 	log.Debug("starting build process")
 	if ioConfig.Source != "." {
@@ -31,16 +30,14 @@ func main() {
 			log.Panic("could not change to source directory")
 		}
 	}
+	cfg, err := config.GetConfig(ioConfig.Source, log)
+	if err != nil {
+		log.Error("could not read config", "error", err)
+	}
 
-	if err := builder.BuildSite(ioConfig); err != nil {
-		switch cause := errors.Cause(err).(type) {
-		case *fs.PathError:
-			log.Error("path error", "error", err)
-		case toml.ParseError:
-			log.Info("parse error", "error", err)
-		default:
-			log.Info("other error", "error", err, "cause", errors.Cause(cause))
-		}
+	_, err = builder.BuildSite(ioConfig, cfg, log)
+	if err != nil {
+		log.Error("could not build site", "error", err)
 		os.Exit(1)
 	}
 }
diff --git a/cmd/cspgenerator/cspgenerator.go b/cmd/cspgenerator/cspgenerator.go
index 89d2718..0252d32 100644
--- a/cmd/cspgenerator/cspgenerator.go
+++ b/cmd/cspgenerator/cspgenerator.go
@@ -1,13 +1,12 @@
 package main
 
 import (
-	"website/internal/config"
-	"website/internal/log"
+	"go.alanpearce.eu/website/internal/config"
 )
 
 func main() {
 	err := config.GenerateCSP()
 	if err != nil {
-		log.Fatal("error generating csp", "error", err)
+		panic(err)
 	}
 }
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 51a9e52..be36e3b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -1,21 +1,16 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"os/signal"
-	"sync"
 
-	"website/internal/log"
-	"website/internal/server"
+	"go.alanpearce.eu/website/internal/server"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/ardanlabs/conf/v3"
-	"github.com/pkg/errors"
-)
-
-var (
-	CommitSHA string
-	ShortSHA  string
+	"gitlab.com/tozd/go/errors"
 )
 
 func main() {
@@ -28,45 +23,41 @@ func main() {
 		}
 		panic("parsing runtime configuration" + err.Error())
 	}
-	log.Configure(!runtimeConfig.Development)
+	log := log.Configure(!runtimeConfig.Development)
+
+	if listenAddress := runtimeConfig.ListenAddress; listenAddress[0] == '[' {
+		runtimeConfig.ListenAddress = listenAddress[1 : len(listenAddress)-1]
+	}
 
 	if runtimeConfig.Development {
 		tmpdir, err := os.MkdirTemp("", "website")
 		if err != nil {
-			log.Fatal("could not create temporary directory to build website: %v", err)
+			log.Fatal("could not create temporary directory", "error", err)
 		}
-		runtimeConfig.Root = tmpdir
 		defer os.RemoveAll(tmpdir)
+		runtimeConfig.Root = tmpdir
 	}
 
-	c := make(chan os.Signal, 2)
-	signal.Notify(c, os.Interrupt)
-	sv, err := server.New(&runtimeConfig)
+	sv, err := server.New(&runtimeConfig, log)
 	if err != nil {
-		log.Fatal("error setting up server", "error", err)
+		log.Error("could not create server", "error", err)
+
+		return
 	}
-	wg := &sync.WaitGroup{}
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
-		sig := <-c
-		log.Info("signal captured", "sig", sig)
-		<-sv.Stop()
-		log.Debug("server stopped")
-	}()
 
-	sErr := make(chan error)
-	wg.Add(1)
+	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
+	defer cancel()
+
 	go func() {
-		defer wg.Done()
-		sErr <- sv.Start()
+		err = sv.Start()
+		if err != nil {
+			// Error starting or closing listener:
+			log.Fatal("error %v", err)
+		}
 	}()
-	log.Info("server listening", "address", sv.Addr)
 
-	err = <-sErr
-	if err != nil {
-		// Error starting or closing listener:
-		log.Fatal("error", "error", err)
-	}
-	wg.Wait()
+	<-ctx.Done()
+	log.Debug("calling stop")
+	sv.Stop()
+	log.Debug("done")
 }
diff --git a/config.toml b/config.toml
index fa26b27..a1302ee 100644
--- a/config.toml
+++ b/config.toml
@@ -7,9 +7,21 @@ description = "Developer, Emacs User"
 
 domain_start_date = "2014-06-07"
 original_domain = "alanpearce.eu"
+domains = [
+  "alanpearce.eu",
+  "www.alanpearce.eu",
+  "alanpearce.uk",
+  "www.alanpearce.uk",
+  "aln.pe",
+  "*.aln.pe",
+]
 
 oidc_host = "https://id.alanpearce.eu/"
 
+goatcounter = "https://stats.alanpearce.eu/count"
+
+wildcard_domain = "aln.pe"
+
 [[taxonomies]]
   name = "tags"
   feed = true
@@ -18,6 +30,9 @@ oidc_host = "https://id.alanpearce.eu/"
   default-src = [
     "'none'",
   ]
+  img-src = [
+    "'self'",
+  ]
   form-action = [
     "'none'",
   ]
@@ -26,24 +41,25 @@ oidc_host = "https://id.alanpearce.eu/"
   ]
   image-src = [
     "'self'",
-    "https://gc.zgo.at",
+    "https://stats.alanpearce.eu",
   ]
   script-src = [
     "'self'",
-    "https://gc.zgo.at",
+    "https://stats.alanpearce.eu",
   ]
   style-src = [
-    "'unsafe-inline'",
     ## index.html style
-    "'sha256-DYuGgioh+cRlROdWp15359Pi5I4iDhP2QHeLZ7WL0uU='",
+    "'sha256-dCSzNS1o8vygl80V2G2nPTiSOUNvyDnW+06hHS4ZdHQ='",
     ## atom.xml style
-    "'sha256-dHnyLX2LnmRFIAOwsOm0FCUVObCfNL0kqAhVUJMjIMk='",
+    "'sha256-CFhPA4p8skr5MyhReF+Tk2GzmYzXdECg8zm+o7EOVPI=",
+    ## cv style
+    "'sha256-FzrPQ6x2ugtwEN5peA8OqpIvrV3uXqkpfUMHG8P2uT0='",
   ]
   frame-ancestors = [
     "https://kagi.com",
   ]
   connect-src = [
-    "https://alanpearce-eu.goatcounter.com/count",
+    "https://stats.alanpearce.eu/count",
   ]
   require-trusted-types-for = [
     "'script'",
@@ -54,6 +70,7 @@ oidc_host = "https://id.alanpearce.eu/"
   x-content-type-options = "nosniff"
   referrer-policy = "strict-origin-when-cross-origin"
   strict-transport-security = "max-age=63072000; includeSubDomains; preload"
+  cross-origin-resource-policy = "same-site"
 
 [[menus.main]]
   name = "Home"
diff --git a/content/_index.md b/content/index.md
index 7239667..7239667 100644
--- a/content/_index.md
+++ b/content/index.md
diff --git a/content/post/searchix.md b/content/post/searchix.md
new file mode 100644
index 0000000..6e1962b
--- /dev/null
+++ b/content/post/searchix.md
@@ -0,0 +1,33 @@
+---
+title: "Announcing Searchix: Nix ecosystem search"
+date: 2024-07-11T15:05:00+02:00
+taxonomies:
+  tags: [nix, searchix]
+---
+
+I decided to create a search tool for NixOS, nix-darwin and
+home-manager options and packages, potentially with more sources to
+come.
+
+I called it [Searchix](https://searchix.alanpearce.eu/)
+
+For NixOS options and packages,
+[search.nixos.org](https://search.nixos.org) already exists (and works
+better than Searchix), whereas for home-manager and nix-darwin, I
+couldn't manage to find a web-based search tool that's still
+online[^1]. So I wrote one.
+
+What I'm happy about with it is that it doesn't _require_ JavaScript,
+but, if it's enabled, makes things a little bit better.  It's only my
+second project in Golang, so I still have things to learn, but I
+definitely think I will enjoy using this language further.
+
+It still has quite a bit of room for improvement, but I've been using
+it quite a lot recently, which leads me to think that other people
+might like to use it, too.
+
+If anyone would like to contribute, raise an issue, or host an
+instance themselves, the [project site and source code are on sr.ht](https://sr.ht/~alanpearce/searchix/).
+
+[^1]: I found [Home Manager Option Search](https://home-manager-options.extranix.com/) after I
+    started this project, but nothing for nix-darwin.
diff --git a/content/post/when-tailscale-magicdns-isn't.md b/content/post/when-tailscale-magicdns-isn't.md
new file mode 100644
index 0000000..2c611a4
--- /dev/null
+++ b/content/post/when-tailscale-magicdns-isn't.md
@@ -0,0 +1,200 @@
+---
+title: When Tailscale MagicDNS isn’t
+description: Frustrations of a NixOS user
+date: 2024-06-23T10:57:00+02:00
+taxonomies:
+  tags: [nixos]
+---
+
+On a router, I have [dnsmasq](https://dnsmasq.org/doc.html) and [kresd](https://knot-resolver.readthedocs.io/en/stable/) as DNS servers. Dnsmasq is accessible on the LAN interface and forwards queries to kresd, which is accessible on the loopback interface.  This has been working for a long time.
+
+I recently set up [Tailscale](https://tailscale.com/) and was confused as to why [MagicDNS](https://tailscale.com/kb/1081/magicdns) wasn’t working on this one device (I have two other NixOS devices that didn’t have any problems). I’m no stranger to investigating these problems, after using and tinkering with networking on Linux/FreeBSD for many years and if there’s a problem, [it’s always DNS](https://isitdns.com/).
+
+My first look at `/etc/resolv.conf` suggested things should be fine, I thought.
+
+```
+# Generated by resolvconf
+search my-network.ts.net
+nameserver 127.0.0.1
+nameserver ::1
+```
+
+:::{.aside}
+‘why is this even generated by `resolvconf`?’, I ask myself: this is a router with a static networking configuration and custom upstream nameservers. I’ll investigate that later, I tell myself.
+:::
+
+I eventually realised that it should be using 100.100.100.100 for two reasons.
+
+1. A working machine’s `/etc/resolv.conf` contains:
+
+	```
+	# Generated by resolvconf
+	search my-network.ts.net
+	nameserver 100.100.100.100
+	options edns0
+	```
+
+2. The Tailscale dashboard gives me a *hint* under the nameservers section:
+> ### Nameservers
+> Set the nameservers used by devices on your network to resolve DNS queries. [Learn more ↗](https://tailscale.com/kb/1054/dns)
+>
+> *my-network*.ts.net ✨MagicDNS\
+> 100.100.100.100
+
+The [linked documentation (DNS in Tailscale)](https://tailscale.com/kb/1054/dns)  doesn’t even mention 100.100.100.100, nor does the documentation for [MagicDNS](https://tailscale.com/kb/1081/magicdns). It is explained as [part of a blog post under the heading ‘how MagicDNS works’](https://tailscale.com/blog/2021-09-private-dns-with-magicdns#how-magicdns-works), but that’s not the first place I’d look.
+
+This led me to try to find what might be responsible for the `nameserver 127.0.0.1` setting:
+- I checked for systemd-resolved, but it wasn’t that (the nameserver would have been 127.0.0.53 if it were).
+- I didn’t think it was anything to do with dnsmasq because that’s configured to use the LAN interface, not the loopback.
+- I didn’t think it was kresd either, since another device uses kresd, but does not have the problem. kresd is not listening on a loopback address on the default port 53 on either machine, meaning that if it were doing this, DNS resolution would have been broken on both machines for a long time.
+
+Eventually I stumbled upon something that worked: setting `networking.resolvconf.useLocalResolver = false`. I then started to investigate in order to open an issue with NixOS, but found things got more confusing and I forgot why I was shaving a yak[^7].
+
+---
+
+### Unexpected behaviours
+
+1. The NixOS kresd module [blindly sets `networking.resolvconf.useLocalResolver` to default to `true`](https://github.com/NixOS/nixpkgs/blob/bfb7a882678e518398ce9a31a881538679f6f092/nixos/modules/services/networking/kresd.nix#L113) because [someone ran into resolver loops](https://github.com/NixOS/nixpkgs/pull/124391) and this was accepted on the grounds that it’s [‘good to be consistent’ (with pdns-recursor, for example)](https://github.com/NixOS/nixpkgs/pull/124391#pullrequestreview-667950510).
+    - I consider this ["spooky action at a distance"](https://en.wikipedia.org/wiki/Action_at_a_distance#%22Spooky_action_at_a_distance%22).  I do not think it should do this, but I can see how it *could* be helpful. In enough cases, though? I’m not sure.
+    - In my case, kresd was *never even listening* on localhost port 53, meaning that this default setting would have led to a broken DNS setup, which at least would at least have led me to investigate the right thing at that time.
+			:::{.aside}
+			Why didn’t it, then?
+			:::
+
+2. The NixOS dnsmasq module sets [`networking.resolvconf.useLocalResolver = true` if `services.dnsmasq.resolveLocalQueries = true`](https://github.com/NixOS/nixpkgs/blob/7780e5160e011b39019797a4c4b1a4babc80d1bf/nixos/modules/services/networking/dnsmasq.nix#L150-L151). This is at least less spooky and distant, but still faulty.
+
+	1. I had indeed set `services.dnsmasq.resolveLocalQueries = true`, because I would like to be able to `ping foo.lan` on the router and it made that possible.
+	2. My erroneous assumption was likely that this setting changes the system nameserver to match the listen address of dnsmasq, rather than setting `networking.resolvconf.useLocalResolver`. Setting  `resolveLocalQueries = false` was one of the first things I tried and it didn’t make a difference, which was unexpected (because kresd was setting it).
+		:::{.aside}
+		If that weren’t enough, [`services.dnsmasq.resolveLocalQueries` sets `networking.nameservers` to include 127.0.0.1](https://github.com/NixOS/nixpkgs/blob/7780e5160e011b39019797a4c4b1a4babc80d1bf/nixos/modules/services/networking/dnsmasq.nix#L138-L139), which makes things more confusing, and is wrong because I haven’t configured dnsmasq to listen on this address.
+		:::
+	3. dnsmasq’s `--interface=<interface name>` setting doesn’t work either as I expected or as it is [documented](https://dnsmasq.org/docs/dnsmasq-man.html).
+
+		> **-i, --interface=\<interface name\>**\
+		> Listen only on the specified interface(s).
+
+		 I had this set to listen _only_ on the LAN interface and I could see it nevertheless listening on `*:53` in `lsof`, rather than the addresses of the LAN interface.
+
+		1. This behaviour *is* mentioned, under a different option, `--bind-interfaces`:
+
+			> On systems which support it, dnsmasq binds the wildcard address, even when it is listening on only some interfaces. It then discards requests that it shouldn’t reply to. This has the advantage of working even when interfaces come and go and change address. This option forces dnsmasq to really bind only the interfaces it is listening on. *About the only time when this is useful is when running another nameserver (or another instance of dnsmasq) on the same machine*.
+
+			I had enabled this setting when I set up kresd on the machine, because the last sentence applies to my case and interfaces are not ‘coming and going’[^4]. The naming is weird as it would suggest it works like `--interface`; namely that `--bind-interfaces=<interface name>` would be a reasonable use. Alas, it is a boolean flag and takes no value.
+
+		2. Even when `--interface` and `--bind-interfaces` *are* set, dnsmasq decides to ignore my intent and explicitly listen on loopback. This is weird, but, guess what, documented back under `--interface`:
+
+			> Dnsmasq automatically adds the loopback (local) interface to the list of interfaces to use when the **--interface** option is used.
+
+		:::{.aside}
+		This explains why enabling kresd didn’t break things before; dnsmasq was listening on 127.0.0.1, even though I thought I had told it not to.
+		:::
+
+3. The option name `networking.resolvconf.useLocalResolver` and its [documentation](https://search.nixos.org/options?channel=unstable&show=networking.resolvconf.useLocalResolver&from=0&size=50&sort=relevance&type=packages&query=uselocalresolver) are unclear.
+
+	> Use local DNS server for resolving.
+
+	This sentence adds no additional data not present in the option name. Local to what? I’m on a *Local* Area Network and wish to use a DNS server on the LAN, should I enable this? No, that’s not what this option is for. It could mean local to *this host*, looking at [its usage](https://github.com/NixOS/nixpkgs/blob/bfb7a882678e518398ce9a31a881538679f6f092/nixos/modules/config/resolvconf.nix#L29-L32). (I deliberately avoided combining the words ‘local’ and ‘host’, for reasons below)
+	- Even if it had a clearer name like `useLocalhostResolver`, the inaccuracy would remain, as `getent hosts localhost` and `getent ahosts localhost` both prefer ::1 over 127.0.0.1, not the other way around.
+	- What would have happened if I had enabled this setting with a server listening on ::1 and *not* 127.0.0.1? It would work, but not be the best setting as applications would attempt to reach a nameserver that is not listening on 127.0.0.1.
+	- It might be confusing to reference a hostname when talking about nameserver reachability, since an IP address is required to reach a nameserver and a nameserver <del>is</del> <ins>could be</ins> required to resolve a hostname. Setting `nameserver localhost` in `/etc/resolv.conf` won’t work, I _thought_.
+
+		- I tried it. Neovim didn’t highlight it as an error[^2] and it did **not** break name resolution, presumably because [`localhost` is added to `/etc/hosts`](https://github.com/NixOS/nixpkgs/blob/bfb7a882678e518398ce9a31a881538679f6f092/nixos/modules/config/networking.nix#L179-L182) *and* is a [special case in `nss-myhostname`](https://www.man7.org/linux/man-pages/man8/nss-myhostname.8.html), the existence of which I might not have known had I not set up [multicast DNS](https://en.wikipedia.org/wiki/Multicast_DNS) to resolve `.local` hostnames in `/etc/nsswitch.conf` in the past,
+
+			> The hostnames "localhost" and "localhost.localdomain" (as
+					well as any hostname ending in ".localhost" or
+					".localhost.localdomain") are resolved to the IP addresses
+					127.0.0.1 and ::1.
+
+			Why is localhost added to `/etc/hosts` if it’s handled by `nss-myhostname` in `/etc/nsswitch.conf?`? The man page of `nsswitch.conf` ([mirror](https://www.man7.org/linux/man-pages/man5/nsswitch.conf.5.html)) gives a small clue:
+
+        > `/etc/nsswitch.conf` is used by the GNU C Library and certain
+        other applications[^5]
+
+      Why isn’t even the choice of a name resolution mechanism for *localhost* unified in 2024?
+
+		- I tried on macOS and iOS. Both allow setting a named nameserver in the GUI, which surprised me. I remember that on earlier versions of Windows (at least on XP, Vista and 7) there were special input boxes that exclusively allowed an IPv4 address, although there was a separate dialogue to input IPv6 addresses. I wonder if that allows hostnames or not, but I don’t have Windows running at the moment to check.
+
+4. Setting `networking.resolvconf.enable = false` doesn’t appear to do.. well.. anything.
+	- The *generated by resolvconf* comment in `/etc/resolv.conf` remains, as do the previous nameserver entries.
+	- `/run/current-system/sw/bin/resolvconf` is not removed.
+	- `man resolvconf` has content (because it’s a link to `man resolvectl`, which is part of `systemd-resolved`, which isn’t even enabled on this system)
+	- I was surprised that `/etc/resolv.conf` was writable at all, as <del>all</del> <ins>many</ins> files under `/etc/` are symlinks to their namesakes under `/etc/static`, which itself is a symlink to a folder in the nix store which contains… more symlinks. Here I use `resolvconf.conf` as an example, i.e. the configuration of `resolvconf`, the program that manages `resolv.conf`.
+
+		```
+		 rwxrwxrwx 1 root root  27 May 26 23:14 /etc/resolvconf.conf -> /etc/static/resolvconf.conf
+		lrwxrwxrwx 1 root root  51 May 26 23:14 /etc/static -> /nix/store/pm0yi93ak5kcvfmidv5lckzfixrh2gck-etc/etc/
+		lrwxrwxrwx 4 root root  63 Jan  1  1970 /nix/store/pm0yi93ak5kcvfmidv5lckzfixrh2gck-etc/etc/resolvconf.conf -> /nix/store/kf0lrhiqqqrc6w96h4qm0sysffnccx2d-etc-resolvconf.conf
+		-r--r--r-- 3 root root 518 Jan  1  1970 /nix/store/kf0lrhiqqqrc6w96h4qm0sysffnccx2d-etc-resolvconf.conf
+		```
+
+		That’s too much indirection for me. If [‘we can solve any problem by introducing an extra level of indirection’](https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering), this suggests that *at least three* problems have been solved here.
+
+		Isn’t it odd that the symlinks are writable to all? I know that `/nix/store` is a read-only filesystem, but it looks odd. Upon searching the web for information, I was directed to the [coreutils `chmod` documentation](https://www.gnu.org/software/coreutils/manual/html_node/chmod-invocation.html):
+
+		> `chmod`  doesn’t change the permissions of symbolic links; the `chmod` system call cannot change their permissions on most systems, and most systems ignore permissions of symbolic links
+
+		_Most_ systems? What does this mean? Is it based on the filesystem used? I would assume that it doesn’t mean ’this is the case on Linux’ given the reference to the system call of the same name and that Linux was not mentioned. The man page for the system call mentions
+
+		> *flags* can either be 0, or include the following flag:
+    > \
+    >   **AT_SYMLINK_NOFOLLOW**\
+    >          If pathname is a symbolic link, do not dereference it:
+    >          instead operate on the link itself.  This flag is not
+    >          currently implemented.
+
+	- The default value of `networking.resolvconf.enable`  is [`!(config.environment.etc ? "resolv.conf")`](https://search.nixos.org/options?channel=unstable&show=networking.resolvconf.enable&from=0&size=50&sort=relevance&type=packages&query=networking.resolvconf.enable), which I understand as _if the content of `resolv.conf` isn’t otherwise assigned_.
+	- There are values in `networking.nameservers`, but these aren’t used as content for `resolv.conf`, which I thought would have been reasonable.
+
+	What *is* the point of `networking.resolvconf.enable`, then? And what about `networking.nameservers`? Where do its values even go? [^6]
+
+4. This issue pushed me to drop flakes on the router so that `nixos-option` would help me as [it does not support flakes](https://github.com/NixOS/nixpkgs/issues/97855)[^3].
+
+5. The Tailscale module [adds `resolvconf` to its path conditionally](https://github.com/NixOS/nixpkgs/blob/7780e5160e011b39019797a4c4b1a4babc80d1bf/nixos/modules/services/networking/tailscale.nix#L87). The [commit adding this condition](https://github.com/NixOS/nixpkgs/commit/922351ec866dcfe1dca4d190bfd3c360933e5cd0) explains that ‘trying to use [resolvconf] always fails because
+`/etc/resolvconf.conf` contains an `exit 1`’, which sounds perfectly reasonable.
+	- If `resolvconf` weren’t in tailscaled’s path, Tailscale would fall back to overwriting resolv.conf, which I found out about because it is a common enough problem/question to warrant [a heading and its own page](https://tailscale.com/kb/1235/resolv-conf).
+
+		This document is the most concise and informative clarification of my original issue; the last paragraph tells me everything I needed to know:
+
+		> Even if you set `--accept-dns=false`, Tailscale’s MagicDNS server still replies at `100.100.100.100` (or `fd7a:115c:a1e0::53`), as long as MagicDNS is enabled on the tailnet. If you’d like to manually configure your DNS configuration, you can point `*.ts.net` queries at `100.100.100.100`.
+
+		Sadly I didn’t look at this page earlier as Tailscale isn’t the one overwriting `/etc/resolv.conf`: it would have set the nameserver to be `100.100.100.100` in that case. Its behaviour is reasonable as ‘there are [an incredible number of ways](https://tailscale.com/blog/sisyphean-dns-client-linux) to configure DNS on Linux’.
+
+		- This blog post suggests that the upcoming (as of April 2021) Tailscale 1.8 will use/prefer using `systemd-resolved` to configure the system resolver
+		- It convinced me that `systemd-resolved` would be the right choice even on a router as the nameserver should depend on the interface. Thanks [Xe](https://xeiaso.net/), I always like your posts!
+
+This should be the end of my issues now then, right?
+
+What happens when I enable `systemd-resolved` and disable `resolvconf`? The hilarity continues:
+
+```
+# resolv.conf(5) file generated by tailscale
+# For more info, see https://tailscale.com/s/resolvconf-overwrite
+# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN
+nameserver 100.100.100.100
+search my-network.ts.net lan my-network.ts.net
+```
+
+I expected Tailscale not to overwrite `resolv.conf` in this scenario, but instead configure `systemd-resolved` (Adding the tailnet to the search domains without checking its presence is yet another issue). I think it’s a race condition that `tailscaled` won that might have been caused by NixOS starting the services at the same time, however `tailscaled.service` has `After=systemd-resolved.service`. Restarting `systemd-resolved` _then_ `tailscaled` explicitly did the right thing:
+
+```
+# This is /run/systemd/resolve/stub-resolv.conf managed by man:systemd-resolved(8).
+# Do not edit.
+# [...]
+nameserver 127.0.0.53
+options edns0 trust-ad
+search lan my-network.ts.net
+```
+
+DNS can be confusing sometimes!
+
+[^2]: vim’s syntax highlighting in `/etc/resolv.conf` marks `nameserver localhost` as an error, which is neat, but somewhat inaccurate here, as this does not appear to be invalid.
+
+[^3]: After reading through the issue, the title does not appear to be accurate given that there are workarounds, however, upon loading the page and seeing that the issue is open since 2020 and has a small scroll bar, indicating many comments, it’s easy to be drawn to the assumption that it continues to be an issue.
+
+[^4]: Since the router has a dynamic <del>public</del> <ins>CGNAT</ins> IP address, it’s true that the addresses are changing, but that’s not relevant to dnsmasq given that it was not ever configured to listen on this interface.
+
+[^5]: As a Wikipedia editor would ask, _which_?
+
+[^6]: I [searched nixpkgs on Github](https://github.com/search?q=repo%3ANixOS%2Fnixpkgs+networking.nameservers&type=code) and found it amusing that all the results where it is set correctly are *in tests*, but the other results show hardcoded definitions.
+
+[^7]: Whilst checking to see if [yak shaving](https://en.wiktionary.org/wiki/yak_shaving) was the right turn of phrase, Wiktionary suggested [‘when you're up to your neck in alligators, it's hard to remember that your initial objective was to drain the swamp’](https://en.wiktionary.org/wiki/when_you%27re_up_to_your_neck_in_alligators,_it%27s_hard_to_remember_that_your_initial_objective_was_to_drain_the_swamp#English) which would be more fitting, but I first learned of this expression today and don’t think it’s as widely-known.
diff --git a/default.nix b/default.nix
index 2cccff2..779ea74 100644
--- a/default.nix
+++ b/default.nix
@@ -1,10 +1,21 @@
-(import
-  (
-    let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
-    fetchTarball {
-      url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
-      sha256 = lock.nodes.flake-compat.locked.narHash;
-    }
-  )
-  { src = ./.; }
-).defaultNix
+let
+  sources = import ./npins;
+
+  pkgs = import sources.nixpkgs { };
+  pre-commit-hooks = import sources.pre-commit-hooks;
+in
+{
+  pre-commit-check = pre-commit-hooks.run {
+    src = ./.;
+    hooks = {
+      go-mod-tidy = {
+        enable = true;
+        name = "go-mod-tidy";
+        description = "Run `go mod tidy`";
+        types_or = [ "go" "go-mod" ];
+        entry = "${pkgs.go}/bin/go mod tidy";
+        pass_filenames = false;
+      };
+    };
+  };
+}
diff --git a/flake.lock b/flake.lock
deleted file mode 100644
index 13e0613..0000000
--- a/flake.lock
+++ /dev/null
@@ -1,193 +0,0 @@
-{
-  "nodes": {
-    "flake-compat": {
-      "flake": false,
-      "locked": {
-        "lastModified": 1696426674,
-        "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
-        "type": "github"
-      },
-      "original": {
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "type": "github"
-      }
-    },
-    "flake-compat_2": {
-      "flake": false,
-      "locked": {
-        "lastModified": 1696426674,
-        "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
-        "type": "github"
-      },
-      "original": {
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "type": "github"
-      }
-    },
-    "gitignore": {
-      "inputs": {
-        "nixpkgs": [
-          "pre-commit-hooks",
-          "nixpkgs"
-        ]
-      },
-      "locked": {
-        "lastModified": 1709087332,
-        "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
-        "owner": "hercules-ci",
-        "repo": "gitignore.nix",
-        "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
-        "type": "github"
-      },
-      "original": {
-        "owner": "hercules-ci",
-        "repo": "gitignore.nix",
-        "type": "github"
-      }
-    },
-    "gomod2nix": {
-      "inputs": {
-        "flake-utils": [
-          "utils"
-        ],
-        "nixpkgs": [
-          "nixpkgs"
-        ]
-      },
-      "locked": {
-        "lastModified": 1710154385,
-        "narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=",
-        "owner": "tweag",
-        "repo": "gomod2nix",
-        "rev": "872b63ddd28f318489c929d25f1f0a3c6039c971",
-        "type": "github"
-      },
-      "original": {
-        "owner": "tweag",
-        "repo": "gomod2nix",
-        "type": "github"
-      }
-    },
-    "nixpkgs": {
-      "locked": {
-        "lastModified": 1715346633,
-        "narHash": "sha256-A9vSieOHR7B41QoWZcb7fEY7r29E4Vq3liXE0h0edf0=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "d42c1c8d447a388e1f2776d22c77f5642d703da6",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixpkgs-unstable",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
-    "nixpkgs-stable": {
-      "locked": {
-        "lastModified": 1710695816,
-        "narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "614b4613980a522ba49f0d194531beddbb7220d3",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixos-23.11",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
-    "nixpkgs_2": {
-      "locked": {
-        "lastModified": 1710765496,
-        "narHash": "sha256-p7ryWEeQfMwTB6E0wIUd5V2cFTgq+DRRBz2hYGnJZyA=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "e367f7a1fb93137af22a3908f00b9a35e2d286a7",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixpkgs-unstable",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
-    "pre-commit-hooks": {
-      "inputs": {
-        "flake-compat": "flake-compat_2",
-        "gitignore": "gitignore",
-        "nixpkgs": "nixpkgs_2",
-        "nixpkgs-stable": "nixpkgs-stable"
-      },
-      "locked": {
-        "lastModified": 1716213921,
-        "narHash": "sha256-xrsYFST8ij4QWaV6HEokCUNIZLjjLP1bYC60K8XiBVA=",
-        "owner": "cachix",
-        "repo": "pre-commit-hooks.nix",
-        "rev": "0e8fcc54b842ad8428c9e705cb5994eaf05c26a0",
-        "type": "github"
-      },
-      "original": {
-        "owner": "cachix",
-        "repo": "pre-commit-hooks.nix",
-        "type": "github"
-      }
-    },
-    "root": {
-      "inputs": {
-        "flake-compat": "flake-compat",
-        "gomod2nix": "gomod2nix",
-        "nixpkgs": "nixpkgs",
-        "pre-commit-hooks": "pre-commit-hooks",
-        "utils": "utils"
-      }
-    },
-    "systems": {
-      "locked": {
-        "lastModified": 1681028828,
-        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
-        "owner": "nix-systems",
-        "repo": "default",
-        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
-        "type": "github"
-      },
-      "original": {
-        "owner": "nix-systems",
-        "repo": "default",
-        "type": "github"
-      }
-    },
-    "utils": {
-      "inputs": {
-        "systems": "systems"
-      },
-      "locked": {
-        "lastModified": 1710146030,
-        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
-        "type": "github"
-      },
-      "original": {
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "type": "github"
-      }
-    }
-  },
-  "root": "root",
-  "version": 7
-}
diff --git a/flake.nix b/flake.nix
deleted file mode 100644
index ad68d77..0000000
--- a/flake.nix
+++ /dev/null
@@ -1,85 +0,0 @@
-{
-  description = "My website, alanpearce.eu";
-  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
-  inputs.utils.url = "github:numtide/flake-utils";
-  inputs.flake-compat = {
-    url = "github:edolstra/flake-compat";
-    flake = false;
-  };
-  inputs.gomod2nix = {
-    url = "github:tweag/gomod2nix";
-    inputs.nixpkgs.follows = "nixpkgs";
-    inputs.flake-utils.follows = "utils";
-  };
-  inputs.pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix";
-
-  outputs = { self, nixpkgs, utils, gomod2nix, pre-commit-hooks, ... }:
-    utils.lib.eachDefaultSystem
-      (system:
-        let
-          pkgs = import nixpkgs {
-            inherit system;
-            overlays = [ gomod2nix.overlays.default ];
-          };
-          packages = import ./nix/default.nix {
-            inherit pkgs self;
-          };
-          commonShellPackages = with pkgs; [
-            just
-            skopeo
-            flyctl
-          ];
-        in
-        {
-          inherit packages;
-          devShells = {
-            ci = pkgs.mkShell {
-              packages = commonShellPackages;
-            };
-            default = pkgs.mkShell {
-              inputsFrom = [ packages.builder ];
-              inherit (self.checks.${system}.pre-commit-check) shellHook;
-              packages = with pkgs; [
-                gopls
-                gotools
-                go-tools
-                gomod2nix.packages.${system}.default
-                gci
-                hyperlink
-                systemfd
-                modd
-                nodePackages.vercel
-                netlify-cli
-              ] ++ commonShellPackages;
-            };
-          };
-          checks = rec {
-            default = hyperlink;
-            hyperlink = pkgs.runCommandLocal "hyperlink" { } ''
-              ${pkgs.hyperlink}/bin/hyperlink ${packages.website}/public
-              touch $out
-            '';
-            pre-commit-check = pre-commit-hooks.lib.${system}.run {
-              src = ./.;
-              hooks = {
-                go-mod-tidy = {
-                  enable = true;
-                  name = "go-mod-tidy";
-                  description = "Run `go mod tidy`";
-                  types_or = [ "go" "go-mod" ];
-                  entry = "${pkgs.go}/bin/go mod tidy";
-                  pass_filenames = false;
-                };
-                gomod2nix = {
-                  enable = true;
-                  name = "gomod2nix";
-                  description = "Import go.mod updates to nix";
-                  types_or = [ "go-sum" ];
-                  entry = "${pkgs.gomod2nix}/bin/gomod2nix --outdir nix";
-                  pass_filenames = false;
-                };
-              };
-            };
-          };
-        });
-}
diff --git a/fly.toml b/fly.toml
index 4fc764b..ef6e3ea 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,4 +1,4 @@
-# fly.toml app configuration file generated for homestead on 2023-09-14T11:40:37+02:00
+# fly.toml app configuration file generated for alanpearce-eu on 2024-06-19T15:17:02+02:00
 #
 # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
 #
@@ -10,37 +10,52 @@ primary_region = "ams"
   image = "registry.fly.io/alanpearce-eu"
 
 [env]
-  PORT = "80"
-  REDIRECT_OTHER_HOSTNAMES = "true"
-  BASE_URL = "https://alanpearce.eu"
-
-[[files]]
-  guest_path = "/config.toml"
-  local_path = "config.toml"
-
-[metrics]
-  port = 9091
-  path = "/metrics"
-
-[http_service]
-  internal_port = 80
-  force_https = true
-  auto_stop_machines = false
-  auto_start_machines = true
-  min_machines_running = 3
-  processes = [ "app" ]
-  [http_service.concurrency]
+  PORT = "8080"
+  TLS_PORT = "8443"
+  LISTEN_ADDRESS = "::"
+  TLS = "true"
+  ROOT = "/data"
+  PRODUCTION = "true"
+  VCS_LOCAL_PATH = "/data/website"
+  VCS_REMOTE_URL = "https://git.alanpearce.eu/website.git"
+  ACME_SERVER_URL = "https://acme.alanpearce.eu"
+
+[[services]]
+  internal_port = 8080
+
+  [services.concurrency]
+    type = "requests"
+    soft_limit = 15000
+
+  [[services.ports]]
+    port = 80
+
+[[services]]
+  internal_port = 8443
+
+  [[services.ports]]
+    port = 443
+
+  [services.concurrency]
     type = "requests"
     soft_limit = 15000
-  [http_service.http_options]
-    h2_backend = true
-    [http_service.http_options.response]
-      pristine = true
-  [[http_service.checks]]
-    grace_period = "15s"
-    interval = "30s"
+
+  [[services.http_checks]]
+    grace_period = "10s"
+    protocol = "https"
+    tls_server_name = "alanpearce.eu"
+    interval = "10s"
     method = "GET"
     timeout = "1s"
     path = "/health"
-    [http_service.checks.headers]
-      Host = "fly-internal"
+
+[[vm]]
+  size = "shared-cpu-1x"
+
+[[restart]]
+  policy = "always"
+
+[mounts]
+  source = "data"
+  destination = "/data"
+  initial_size = "1gb"
diff --git a/go.mod b/go.mod
index 5495a00..4ceba0b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,50 +1,114 @@
-module website
+module go.alanpearce.eu/website
 
-go 1.22.1
+go 1.23
+
+toolchain go1.23.1
 
 require (
-	github.com/BurntSushi/toml v1.3.2
-	github.com/PuerkitoBio/goquery v1.9.2
-	github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e
+	github.com/BurntSushi/toml v1.4.0
+	github.com/PuerkitoBio/goquery v1.10.0
+	github.com/a-h/templ v0.2.778
 	github.com/adrg/frontmatter v0.2.0
-	github.com/antchfx/xmlquery v1.4.0
-	github.com/antchfx/xpath v1.3.0
-	github.com/ardanlabs/conf/v3 v3.1.7
-	github.com/benpate/digit v0.12.0
+	github.com/andybalholm/brotli v1.1.0
+	github.com/antchfx/xmlquery v1.4.1
+	github.com/antchfx/xpath v1.3.1
+	github.com/ardanlabs/conf/v3 v3.1.8
+	github.com/benpate/digit v0.13.1
+	github.com/caddyserver/caddy/v2 v2.8.4
+	github.com/caddyserver/certmagic v0.21.3
 	github.com/crewjam/csp v0.0.2
 	github.com/deckarep/golang-set/v2 v2.6.0
 	github.com/fatih/structtag v1.2.0
 	github.com/fsnotify/fsnotify v1.7.0
+	github.com/go-git/go-git/v5 v5.12.0
+	github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43
+	github.com/libdns/acmedns v0.2.0
 	github.com/osdevisnot/sorvor v0.4.4
-	github.com/otiai10/copy v1.14.0
-	github.com/pkg/errors v0.9.1
+	github.com/pberkel/caddy-storage-redis v1.3.0
 	github.com/snabb/sitemap v1.0.4
 	github.com/stefanfritsch/goldmark-fences v1.0.0
-	github.com/sykesm/zap-logfmt v0.0.4
-	github.com/thessem/zap-prettyconsole v0.4.0
-	github.com/yuin/goldmark v1.7.1
-	go.uber.org/zap v1.27.0
-	golang.org/x/net v0.25.0
+	github.com/yuin/goldmark v1.7.4
+	gitlab.com/tozd/go/errors v0.10.0
+	go.alanpearce.eu/x v0.0.0-20240703154602-00de54be50eb
 )
 
-replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562
-
 require (
+	dario.cat/mergo v1.0.1 // indirect
 	github.com/Code-Hex/dd v1.1.0 // indirect
+	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/ProtonMail/go-crypto v1.0.0 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
+	github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/benpate/derp v0.31.0 // indirect
-	github.com/benpate/domain v0.2.1 // indirect
+	github.com/benpate/domain v0.2.2 // indirect
 	github.com/benpate/exp v0.8.3 // indirect
-	github.com/benpate/remote v0.15.0 // indirect
-	github.com/benpate/rosetta v0.21.0 // indirect
+	github.com/benpate/remote v0.16.2 // indirect
+	github.com/benpate/rosetta v0.22.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/bsm/redislock v0.9.4 // indirect
+	github.com/caddyserver/zerossl v0.1.3 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/cloudflare/circl v1.4.0 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+	github.com/cyphar/filepath-securejoin v0.3.2 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/emirpasic/gods v1.18.1 // indirect
+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+	github.com/go-git/go-billy/v5 v5.5.0 // indirect
+	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+	github.com/google/pprof v0.0.0-20240925223930-fa3061bff0bc // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
-	github.com/microcosm-cc/bluemonday v1.0.26 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.8 // indirect
+	github.com/libdns/libdns v0.2.2 // indirect
+	github.com/mholt/acmez/v2 v2.0.2 // indirect
+	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+	github.com/miekg/dns v1.1.62 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/onsi/ginkgo/v2 v2.20.2 // indirect
+	github.com/pjbgf/sha1cd v0.3.0 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/prometheus/client_golang v1.20.4 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
+	github.com/prometheus/common v0.59.1 // indirect
+	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/quic-go/qpack v0.5.1 // indirect
+	github.com/quic-go/quic-go v0.47.0 // indirect
+	github.com/redis/go-redis/v9 v9.6.1 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
+	github.com/skeema/knownhosts v1.3.0 // indirect
 	github.com/snabb/diagio v1.0.4 // indirect
+	github.com/spf13/cobra v1.8.1 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/sykesm/zap-logfmt v0.0.4 // indirect
+	github.com/thessem/zap-prettyconsole v0.5.2 // indirect
+	github.com/xanzy/ssh-agent v0.3.3 // indirect
+	github.com/zeebo/blake3 v0.2.4 // indirect
+	go.uber.org/automaxprocs v1.6.0 // indirect
+	go.uber.org/mock v0.4.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.20.0 // indirect
-	golang.org/x/text v0.15.0 // indirect
+	go.uber.org/zap v1.27.0 // indirect
+	go.uber.org/zap/exp v0.2.0 // indirect
+	golang.org/x/crypto v0.27.0 // indirect
+	golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377 // indirect
+	golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
+	golang.org/x/mod v0.21.0 // indirect
+	golang.org/x/net v0.29.0 // indirect
+	golang.org/x/sync v0.8.0 // indirect
+	golang.org/x/sys v0.25.0 // indirect
+	golang.org/x/term v0.24.0 // indirect
+	golang.org/x/text v0.18.0 // indirect
+	golang.org/x/time v0.6.0 // indirect
+	golang.org/x/tools v0.25.0 // indirect
+	google.golang.org/protobuf v1.34.2 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	moul.io/zapfilter v1.7.0 // indirect
 )
diff --git a/go.sum b/go.sum
index c65524b..c7fbdb9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,59 +1,156 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/Code-Hex/dd v1.1.0 h1:VEtTThnS9l7WhpKUIpdcWaf0B8Vp0LeeSEsxA1DZseI=
 github.com/Code-Hex/dd v1.1.0/go.mod h1:VaMyo/YjTJ3d4qm/bgtrUkT2w+aYwJ07Y7eCWyrJr1w=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
+github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
+github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
+github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
+github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
 github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4=
 github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE=
-github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562 h1:7LpBXZnmFk8+RwdFnAYB7rKZhBQrQ4poPLEhpwwbmSc=
-github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
-github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA=
-github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI=
-github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc=
-github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/antchfx/xmlquery v1.4.1 h1:YgpSwbeWvLp557YFTi8E3z6t6/hYjmFEtiEKbDfEbl0=
+github.com/antchfx/xmlquery v1.4.1/go.mod h1:lKezcT8ELGt8kW5L+ckFMTbgdR61/odpPgDv8Gvi1fI=
+github.com/antchfx/xpath v1.3.1 h1:PNbFuUqHwWl0xRjvUPjJ95Agbmdj2uzzIwmQKgu4oCk=
+github.com/antchfx/xpath v1.3.1/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
 github.com/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0=
 github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU=
+github.com/ardanlabs/conf/v3 v3.1.8 h1:r0KUV9/Hni5XdeWR2+A1BiedIDnry5CjezoqgJ0rnFQ=
+github.com/ardanlabs/conf/v3 v3.1.8/go.mod h1:OIi6NK95fj8jKFPdZ/UmcPlY37JBg99hdP9o5XmNK9c=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
+github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/benpate/derp v0.31.0 h1:Vo3oQrD+eDLY/FQ4W3HUtV1Et7lkm8OEF6rJQlSd6xg=
 github.com/benpate/derp v0.31.0/go.mod h1:y+PJWv5VOBOnd1y4CGk/c7xVS0Pwxg9BGQE5r/SGc8w=
-github.com/benpate/digit v0.12.0 h1:dYqPKgHhTCvddaI91v/8cvhRTDHxRT2ldZ+OdDcub/I=
-github.com/benpate/digit v0.12.0/go.mod h1:YK9UZljGMC4ZQPFqz95aJgrS99kkVxcghKou7CurhXs=
-github.com/benpate/domain v0.2.1 h1:OGjmZk64KKAuzv2BwBwVkrFjO3Cv/iu0L41+AIydLps=
-github.com/benpate/domain v0.2.1/go.mod h1:Of+W1qgA1fCumChsKVwleSotq1wtSefo15hNvOucMBQ=
+github.com/benpate/digit v0.12.1 h1:2Dx6IJbvvIlPMKJCSG7/XQJ/Y8BDJGVanzrGfLkWq1c=
+github.com/benpate/digit v0.12.1/go.mod h1:wfPxqTL8AMlq4gWlBs+tBQhojRc8sS7CyBEi/rivO8Q=
+github.com/benpate/digit v0.13.1 h1:eCFyeATnbVY9EXWYto4j3IsiBLzPGj5uC0F/YmwoZiE=
+github.com/benpate/digit v0.13.1/go.mod h1:yrih/yDWD5mvxnxzp8uWAQix/ovHcheFgUlRQHPsbU4=
+github.com/benpate/domain v0.2.2 h1:QBjXZz2Tz66izmvdXHV+fkye4lpNcy5N1w86PWC4RwU=
+github.com/benpate/domain v0.2.2/go.mod h1:Mi4/I3iFaa7Kg0c4xFiZ0Wptq1nbKNcv5PENEIKkdPU=
 github.com/benpate/exp v0.8.3 h1:rxlXtdLxc+XU5ySoAFEt7YKOjhOE7YRMz15YUePBLJk=
 github.com/benpate/exp v0.8.3/go.mod h1:VWDkZ1uM4HyXJVi+NDA1QvVklsgFE7T9L5lCeZBento=
-github.com/benpate/remote v0.15.0 h1:Ciwwg97BiyA+gVEsULC4I14TjZbwb9MJaiGV/JvNpZM=
-github.com/benpate/remote v0.15.0/go.mod h1:/+Lv9DLp7QY83HyIdFg+nW9pnVAxmKQjwv5wTTRG1qA=
-github.com/benpate/rosetta v0.21.0 h1:Zm6Fg+2vRdHTLgiVd3lvYRFKvVZeWCG4zB4W8GiEmMw=
-github.com/benpate/rosetta v0.21.0/go.mod h1:z5O9VBmsqAcLfUh1OCthkwaF7URC5vyxuRMO+mQl/fk=
+github.com/benpate/remote v0.16.0 h1:YFXsLRjJNBPAVSZTEsgaOG7kMSHYn36z/1DmsrZU/FY=
+github.com/benpate/remote v0.16.0/go.mod h1:6OeZOYeEUyF0HDFaL1QPY9yboU3EvGYTNdABiRBNiF0=
+github.com/benpate/remote v0.16.2 h1:bKMEtBLemQzUaXNGHomSGk9QojV0aoV2arx/P8H3wC4=
+github.com/benpate/remote v0.16.2/go.mod h1:6OeZOYeEUyF0HDFaL1QPY9yboU3EvGYTNdABiRBNiF0=
+github.com/benpate/rosetta v0.21.2 h1:tBIfVzCv7vyLBZtF0ETAHDsvIKc28hCVBbLL7QE42pw=
+github.com/benpate/rosetta v0.21.2/go.mod h1:xH4gwL4OANy3PiaRq/ED4R9tU3oZ9atvH/nBzVLIfBg=
+github.com/benpate/rosetta v0.22.0 h1:FLKUMCySEseiLwZ37ZIYx9g8ZkATSx6CasGcDtLvnOY=
+github.com/benpate/rosetta v0.22.0/go.mod h1:xH4gwL4OANy3PiaRq/ED4R9tU3oZ9atvH/nBzVLIfBg=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw=
+github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/caddyserver/caddy/v2 v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=
+github.com/caddyserver/caddy/v2 v2.8.4/go.mod h1:vmDAHp3d05JIvuhc24LmnxVlsZmWnUwbP5WMjzcMPWw=
+github.com/caddyserver/certmagic v0.21.3 h1:pqRRry3yuB4CWBVq9+cUqu+Y6E2z8TswbhNx1AZeYm0=
+github.com/caddyserver/certmagic v0.21.3/go.mod h1:Zq6pklO9nVRl3DIFUw9gVUfXKdpc/0qwTUAQMBlfgtI=
+github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
+github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE=
+github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
+github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY=
+github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
+github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
+github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/crewjam/csp v0.0.2 h1:fIq6o0Z6bkABlvLT3kB0XgPnVX9iNXSAGMILs6AqHVw=
 github.com/crewjam/csp v0.0.2/go.mod h1:0tirp4wHwMLZZtV+HXRqGFkUO7uD2ux+1ECvK+7/xFI=
+github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
+github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/cyphar/filepath-securejoin v0.3.2 h1:QhZu5AxQ+o1XZH0Ye05YzvJ0kAdK6VQc0z9NNMek7gc=
+github.com/cyphar/filepath-securejoin v0.3.2/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
 github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/evanw/esbuild v0.14.11/go.mod h1:GG+zjdi59yh3ehDn4ZWfPcATxjPDUH53iU4ZJbp7dkY=
 github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
 github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
+github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
+github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
 github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20240925223930-fa3061bff0bc h1:7bf8bGo4akhLJrmttkYLjxIz0yQmBi5umb+Nj1qRPpE=
+github.com/google/pprof v0.0.0-20240925223930-fa3061bff0bc/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
+github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
+github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -61,113 +158,260 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/libdns/acmedns v0.2.0 h1:zTXdHZwe3r2issdVRyqt5/4X2yHpiBVmFnTrwBA29ik=
+github.com/libdns/acmedns v0.2.0/go.mod h1:XlKHilQQK/IGHYY//vCb903PdG4Wc/XnDQzcMp2hV3g=
+github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
+github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
+github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
+github.com/mholt/acmez/v2 v2.0.2 h1:OmK6xckte2JfKGPz4OAA8aNHTiLvGp8tLzmrd/wfSyw=
+github.com/mholt/acmez/v2 v2.0.2/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U=
 github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
 github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
+github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
+github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 github.com/osdevisnot/sorvor v0.4.4 h1:hcMWsWOKpUtDUE3F7dra1Jf12ftLHfgDcxlyPeVlz0Y=
 github.com/osdevisnot/sorvor v0.4.4/go.mod h1:D/j+vvJEmjIXndJf37uwFWD0Hjcq9DiGojyt4yMo7H0=
-github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
-github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
-github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
-github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
+github.com/pberkel/caddy-storage-redis v1.2.0 h1:CqJ9K4z2DAHF+euR0295QjEfNhbV/HAeujajeWau8ww=
+github.com/pberkel/caddy-storage-redis v1.2.0/go.mod h1:ztw2IxbDCQ+NUrD841IvdcwiGyh1m1HgB2nBj/geS4I=
+github.com/pberkel/caddy-storage-redis v1.3.0 h1:3s/FBAtOH/VYxZpk5ybDp6VXt5f3DX+1mIB050InIm4=
+github.com/pberkel/caddy-storage-redis v1.3.0/go.mod h1:loek+dY7iDR/ZcCg+DsWC8MZCQo48UWFcgzg8MDlQuo=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
+github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
+github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0=
+github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.47.0 h1:yXs3v7r2bm1wmPTYNLKAAJTHMYkPEsfYJmTazXrCZ7Y=
+github.com/quic-go/quic-go v0.47.0/go.mod h1:3bCapYsJvXGZcipOHuu7plYtaV6tnF+z7wIFsU0WK9E=
+github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU=
+github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
+github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
+github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
+github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
+github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
 github.com/snabb/diagio v1.0.4 h1:XnlKoBarZWiAEnNBYE5t1nbvJhdaoTaW7IBzu0R4AqM=
 github.com/snabb/diagio v1.0.4/go.mod h1:Y+Pja4UJrskCOKaLxOfa8b8wYSVb0JWpR4YFNHuzjDI=
 github.com/snabb/sitemap v1.0.4 h1:BC6cPW5jXLsKWtlYQKD2s1W58CarvNzqOmdl680uQPw=
 github.com/snabb/sitemap v1.0.4/go.mod h1:815/fxQQ8Tt7Eqwe8Lcat4ax73zuHyPxWBZySnbaxkc=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stefanfritsch/goldmark-fences v1.0.0 h1:cAL9eFJx5AfODfzURJg/R4M0TdynZb4azpGtXebywCI=
 github.com/stefanfritsch/goldmark-fences v1.0.0/go.mod h1:afDcGjekNr4uEUtTuDNmU+yPElZkv0bF2ASp+KoYsDk=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/sykesm/zap-logfmt v0.0.4 h1:U2WzRvmIWG1wDLCFY3sz8UeEmsdHQjHFNlIdmroVFaI=
 github.com/sykesm/zap-logfmt v0.0.4/go.mod h1:AuBd9xQjAe3URrWT1BBDk2v2onAZHkZkWRMiYZXiZWA=
-github.com/thessem/zap-prettyconsole v0.4.0 h1:905GshsxOSVz44hm0mEr00Dk/gjf1Aoq3tkW1o2kKuw=
-github.com/thessem/zap-prettyconsole v0.4.0/go.mod h1:6bRZpKuje/vxEMEAlF22qPHkcO8kfJSrL/0NALxuZoQ=
+github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
+github.com/thessem/zap-prettyconsole v0.5.0 h1:AOu1GGUuDkGmj4tgRPSVf0vYGzDM+6cPWjKOcmjEcQs=
+github.com/thessem/zap-prettyconsole v0.5.0/go.mod h1:3qfsE7y+bLOq7EQ+fMZHD3HYEp24ULFf5nhLSx6rjrE=
+github.com/thessem/zap-prettyconsole v0.5.2 h1:knusxXGhmkD5Ho+WiI4IzD16Dz9PEcOIKdK+uX4oTPA=
+github.com/thessem/zap-prettyconsole v0.5.2/go.mod h1:3qfsE7y+bLOq7EQ+fMZHD3HYEp24ULFf5nhLSx6rjrE=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
-github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
+github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+gitlab.com/tozd/go/errors v0.8.1 h1:RfylffRAsl3PbDdHNUBEkTleTCiL/RIT+Ef8p0HRNCI=
+gitlab.com/tozd/go/errors v0.8.1/go.mod h1:PvIdUMLpPwxr+KEBxghQaCMydHXGYdJQn/PhdMqYREY=
+gitlab.com/tozd/go/errors v0.10.0 h1:A98kL+gaDvWnY6ZB/u8zP+sYaWsWUGBHeFMtamvW/74=
+gitlab.com/tozd/go/errors v0.10.0/go.mod h1:q3Ugr0C8dCzMEkrzjjlV2qNsm9e0KvqBjwcbcjCpBe4=
+go.alanpearce.eu/x v0.0.0-20240703154602-00de54be50eb h1:jJawVAo7/RFNbXGAyBSpX8ADb63G/nBideRgveBvmpA=
+go.alanpearce.eu/x v0.0.0-20240703154602-00de54be50eb/go.mod h1:FRM6J9HMQ/RV2Q5j+6RKBYWh/YNeEUriGSqDRchiHuQ=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
+go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
+go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
 go.uber.org/zap v1.12.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.20.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs=
+go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+golang.org/x/crypto/x509roots/fallback v0.0.0-20240624163532-1c7450041f58 h1:FQBNkSe29+6BkKYNIXZkLKylo2wpq7HGYfxUlv8bfEk=
+golang.org/x/crypto/x509roots/fallback v0.0.0-20240624163532-1c7450041f58/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
+golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377 h1:aDWu69N3Si4isYMY1ppnuoGEFypX/E5l4MWA//GPClw=
+golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
+golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
+golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
 golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
+golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
+golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
+golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+moul.io/zapfilter v1.7.0 h1:7aFrG4N72bDH9a2BtYUuUaDS981Dxu3qybWfeqaeBDU=
+moul.io/zapfilter v1.7.0/go.mod h1:M+N2s+qZiA+bzRoyKMVRxyuERijS2ovi2pnMyiOGMvc=
diff --git a/internal/atom/atom.go b/internal/atom/atom.go
index 37c53d9..f75d18a 100644
--- a/internal/atom/atom.go
+++ b/internal/atom/atom.go
@@ -1,33 +1,52 @@
 package atom
 
 import (
+	"bytes"
 	"encoding/xml"
+	"net/url"
 	"time"
 
-	"website/internal/config"
+	"go.alanpearce.eu/website/internal/config"
 )
 
-func MakeTagURI(config config.Config, specific string) string {
+func MakeTagURI(config *config.Config, specific string) string {
 	return "tag:" + config.OriginalDomain + "," + config.DomainStartDate + ":" + specific
 }
 
+func LinkXSL(w *bytes.Buffer, url string) error {
+	_, err := w.WriteString(`<?xml-stylesheet href="`)
+	if err != nil {
+		return err
+	}
+	err = xml.EscapeText(w, []byte(url))
+	if err != nil {
+		return err
+	}
+	_, err = w.WriteString(`" type="text/xsl"?>`)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 type Link struct {
 	XMLName xml.Name `xml:"link"`
-	Rel     string   `xml:"rel,attr"`
-	Type    string   `xml:"type,attr"`
+	Rel     string   `xml:"rel,attr,omitempty"`
+	Type    string   `xml:"type,attr,omitempty"`
 	Href    string   `xml:"href,attr"`
 }
 
-func MakeLink(url string) Link {
+func MakeLink(url *url.URL) Link {
 	return Link{
 		Rel:  "alternate",
 		Type: "text/html",
-		Href: url,
+		Href: url.String(),
 	}
 }
 
 type FeedContent struct {
-	Content string `xml:",innerxml"`
+	Content string `xml:",chardata"`
 	Type    string `xml:"type,attr"`
 }
 
@@ -41,3 +60,12 @@ type FeedEntry struct {
 	Content FeedContent `xml:"content"`
 	Author  string      `xml:"author>name"`
 }
+
+type Feed struct {
+	XMLName xml.Name     `xml:"http://www.w3.org/2005/Atom feed"`
+	Title   string       `xml:"title"`
+	Link    Link         `xml:"link"`
+	ID      string       `xml:"id"`
+	Updated time.Time    `xml:"updated"`
+	Entries []*FeedEntry `xml:"entry"`
+}
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index b17fbc2..b99d919 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -1,54 +1,80 @@
 package builder
 
 import (
+	"context"
 	"fmt"
 	"io"
-	"net/url"
 	"os"
 	"path"
+	"path/filepath"
 	"slices"
-	"sync"
 	"time"
 
-	"website/internal/config"
-	"website/internal/log"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+	"go.alanpearce.eu/x/log"
+	"go.alanpearce.eu/website/internal/sitemap"
+	"go.alanpearce.eu/website/templates"
 
-	cp "github.com/otiai10/copy"
-	"github.com/pkg/errors"
-	"github.com/snabb/sitemap"
+	"github.com/a-h/templ"
+	mapset "github.com/deckarep/golang-set/v2"
+	"gitlab.com/tozd/go/errors"
 )
 
 type IOConfig struct {
 	Source      string `conf:"default:.,short:s,flag:src"`
-	Destination string `conf:"default:website,short:d,flag:dest"`
-	BaseURL     config.URL
-	Development bool `conf:"default:false,flag:dev"`
+	Destination string `conf:"default:public,short:d,flag:dest"`
+	Development bool   `conf:"default:false,flag:dev"`
 }
 
+type Result struct {
+	Hashes []string
+}
+
+var compressFiles = false
+
 func mkdirp(dirs ...string) error {
 	err := os.MkdirAll(path.Join(dirs...), 0755)
 
 	return errors.Wrap(err, "could not create directory")
 }
 
-func outputToFile(output io.Reader, filename ...string) error {
-	log.Debug("outputting file", "filename", path.Join(filename...))
-	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+func outputToFile(output io.Reader, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", filename)
+	file, err := openFileAndVariants(filename)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
 	}
 	defer file.Close()
 
-	if _, err := file.ReadFrom(output); err != nil {
+	if _, err := io.Copy(file, output); err != nil {
 		return errors.WithMessage(err, "could not write output file")
 	}
 
 	return nil
 }
 
-func writerToFile(writer io.WriterTo, filename ...string) error {
-	log.Debug("outputting file", "filename", path.Join(filename...))
-	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+func renderToFile(component templ.Component, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", filename)
+	file, err := openFileAndVariants(filename)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if err := component.Render(context.TODO(), file); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+func writerToFile(writer io.WriterTo, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", path.Join(filename...))
+	file, err := openFileAndVariants(filename)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
 	}
@@ -61,206 +87,185 @@ func writerToFile(writer io.WriterTo, filename ...string) error {
 	return nil
 }
 
-func build(outDir string, config config.Config) error {
-	log.Debug("output", "dir", outDir)
-	assetsOnce = sync.Once{}
-	privateDir := path.Join(outDir, "private")
-	if err := mkdirp(privateDir); err != nil {
-		return errors.WithMessage(err, "could not create private directory")
+func joinSourcePath(src string) func(string) string {
+	return func(rel string) string {
+		return filepath.Join(src, rel)
 	}
-	publicDir := path.Join(outDir, "public")
-	if err := mkdirp(publicDir); err != nil {
-		return errors.WithMessage(err, "could not create public directory")
+}
+
+func build(ioConfig *IOConfig, config *config.Config, log *log.Logger) (*Result, error) {
+	outDir := ioConfig.Destination
+	joinSource := joinSourcePath(ioConfig.Source)
+	log.Debug("output", "dir", outDir)
+	r := &Result{
+		Hashes: make([]string, 0),
 	}
 
-	err := cp.Copy("static", publicDir, cp.Options{
-		PreserveTimes:     true,
-		PermissionControl: cp.AddPermission(0755),
-	})
+	err := copyRecursive(joinSource("static"), outDir)
 	if err != nil {
-		return errors.WithMessage(err, "could not copy static files")
+		return nil, errors.WithMessage(err, "could not copy static files")
 	}
 
-	if err := mkdirp(publicDir, "post"); err != nil {
-		return errors.WithMessage(err, "could not create post output directory")
+	if err := mkdirp(outDir, "post"); err != nil {
+		return nil, errors.WithMessage(err, "could not create post output directory")
 	}
 	log.Debug("reading posts")
-	posts, tags, err := readPosts("content", "post", publicDir)
+	posts, tags, err := content.ReadPosts(&content.Config{
+		Root:      joinSource("content"),
+		InputDir:  "post",
+		OutputDir: outDir,
+	}, log.Named("content"))
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	sm := NewSitemap(config)
+	sitemap := sitemap.New(config)
 	lastMod := time.Now()
 	if len(posts) > 0 {
 		lastMod = posts[0].Date
 	}
 
 	for _, post := range posts {
-		if err := mkdirp(publicDir, "post", post.Basename); err != nil {
-			return errors.WithMessage(err, "could not create directory for post")
+		if err := mkdirp(outDir, "post", post.Basename); err != nil {
+			return nil, errors.WithMessage(err, "could not create directory for post")
 		}
 		log.Debug("rendering post", "post", post.Basename)
-		sm.Add(&sitemap.URL{
-			Loc:     post.URL,
-			LastMod: &post.Date,
-		})
-		output, err := renderPost(post, config)
-		if err != nil {
-			return errors.WithMessagef(err, "could not render post %s", post.Input)
-		}
-		if err := outputToFile(output, post.Output); err != nil {
-			return err
+		sitemap.AddPath(post.URL, post.Date)
+		if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil {
+			return nil, err
 		}
 	}
 
-	if err := mkdirp(publicDir, "tags"); err != nil {
-		return errors.WithMessage(err, "could not create directory for tags")
+	if err := mkdirp(outDir, "tags"); err != nil {
+		return nil, errors.WithMessage(err, "could not create directory for tags")
 	}
 	log.Debug("rendering tags list")
-	output, err := renderTags(tags, config, "/tags")
-	if err != nil {
-		return errors.WithMessage(err, "could not render tags")
+	if err := renderToFile(
+		templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"),
+		outDir,
+		"tags",
+		"index.html",
+	); err != nil {
+		return nil, err
 	}
-	if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil {
-		return err
-	}
-	sm.Add(&sitemap.URL{
-		Loc:     "/tags/",
-		LastMod: &lastMod,
-	})
+	sitemap.AddPath("/tags/", lastMod)
 
 	for _, tag := range tags.ToSlice() {
-		matchingPosts := []Post{}
+		matchingPosts := []content.Post{}
 		for _, post := range posts {
 			if slices.Contains(post.Taxonomies.Tags, tag) {
 				matchingPosts = append(matchingPosts, post)
 			}
 		}
-		if err := mkdirp(publicDir, "tags", tag); err != nil {
-			return errors.WithMessage(err, "could not create directory")
+		if err := mkdirp(outDir, "tags", tag); err != nil {
+			return nil, errors.WithMessage(err, "could not create directory")
 		}
 		log.Debug("rendering tags page", "tag", tag)
 		url := "/tags/" + tag
-		output, err := renderListPage(tag, config, matchingPosts, url)
-		if err != nil {
-			return errors.WithMessage(err, "could not render tag page")
-		}
-		if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil {
-			return err
+		if err := renderToFile(
+			templates.TagPage(config, tag, matchingPosts, url),
+			outDir,
+			"tags",
+			tag,
+			"index.html",
+		); err != nil {
+			return nil, err
 		}
-		sm.Add(&sitemap.URL{
-			Loc:     url,
-			LastMod: &matchingPosts[0].Date,
-		})
+		sitemap.AddPath(url, matchingPosts[0].Date)
 
 		log.Debug("rendering tags feed", "tag", tag)
-		output, err = renderFeed(
+		feed, err := renderFeed(
 			fmt.Sprintf("%s - %s", config.Title, tag),
 			config,
 			matchingPosts,
 			tag,
 		)
 		if err != nil {
-			return errors.WithMessage(err, "could not render tag feed page")
+			return nil, errors.WithMessage(err, "could not render tag feed page")
 		}
-		if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil {
-			return err
+		if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil {
+			return nil, err
 		}
 	}
 
 	log.Debug("rendering list page")
-	listPage, err := renderListPage("", config, posts, "/post")
-	if err != nil {
-		return errors.WithMessage(err, "could not render list page")
+	if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil {
+		return nil, err
 	}
-	if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil {
-		return err
-	}
-	sm.Add(&sitemap.URL{
-		Loc:     "/post/",
-		LastMod: &lastMod,
-	})
+	sitemap.AddPath("/post/", lastMod)
 
 	log.Debug("rendering feed")
 	feed, err := renderFeed(config.Title, config, posts, "feed")
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed")
+		return nil, errors.WithMessage(err, "could not render feed")
 	}
-	if err := outputToFile(feed, publicDir, "atom.xml"); err != nil {
-		return err
+	if err := writerToFile(feed, outDir, "atom.xml"); err != nil {
+		return nil, err
 	}
 
 	log.Debug("rendering feed styles")
-	feedStyles, err := renderFeedStyles()
+	feedStyles, err := renderFeedStyles(ioConfig.Source)
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not render feed styles")
+	}
+	if err := outputToFile(feedStyles, outDir, "feed-styles.xsl"); err != nil {
+		return nil, err
+	}
+	_, err = feedStyles.Seek(0, 0)
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed styles")
+		return nil, err
 	}
-	if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil {
-		return err
+	h, err := getFeedStylesHash(feedStyles)
+	if err != nil {
+		return nil, err
 	}
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering homepage")
-	homePage, err := renderHomepage(config, posts, "/")
+	_, text, err := content.GetPost(joinSource(filepath.Join("content", "index.md")))
+	if err != nil {
+		return nil, err
+	}
+	content, err := content.RenderMarkdown(text)
 	if err != nil {
-		return errors.WithMessage(err, "could not render homepage")
+		return nil, err
 	}
-	if err := outputToFile(homePage, publicDir, "index.html"); err != nil {
-		return err
+	if err := renderToFile(templates.Homepage(config, posts, content), outDir, "index.html"); err != nil {
+		return nil, err
 	}
 	// it would be nice to set LastMod here, but using the latest post
 	// date would be wrong as the homepage has its own content file
 	// without a date, which could be newer
-	sm.Add(&sitemap.URL{
-		Loc: "/",
-	})
-
-	log.Debug("rendering 404 page")
-	notFound, err := render404(config, "/404.html")
-	if err != nil {
-		return errors.WithMessage(err, "could not render 404 page")
-	}
-	if err := outputToFile(notFound, publicDir, "404.html"); err != nil {
-		return err
-	}
+	sitemap.AddPath("/", time.Time{})
+	h, _ = getHTMLStyleHash(outDir, "index.html")
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering sitemap")
-	if err := writerToFile(sm, publicDir, "sitemap.xml"); err != nil {
-		return err
+	if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil {
+		return nil, err
 	}
 
 	log.Debug("rendering robots.txt")
-	rob, err := renderRobotsTXT(config)
+	rob, err := renderRobotsTXT(ioConfig.Source, config)
 	if err != nil {
-		return err
+		return nil, err
 	}
-	if err := outputToFile(rob, publicDir, "robots.txt"); err != nil {
-		return err
+	if err := outputToFile(rob, outDir, "robots.txt"); err != nil {
+		return nil, err
 	}
 
-	return nil
+	return r, nil
 }
 
-func BuildSite(ioConfig IOConfig) error {
-	config, err := config.GetConfig()
-	if err != nil {
-		return errors.WithMessage(err, "could not get config")
+func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) {
+	if cfg == nil {
+		return nil, errors.New("config is nil")
 	}
-	config.InjectLiveReload = ioConfig.Development
+	cfg.InjectLiveReload = ioConfig.Development
+	compressFiles = !ioConfig.Development
 
-	if ioConfig.BaseURL.URL != nil {
-		config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String())
-		if err != nil {
-			return errors.WithMessage(err, "could not re-parse base URL")
-		}
-	}
-
-	if ioConfig.Development && ioConfig.Destination != "website" {
-		err = os.RemoveAll(ioConfig.Destination)
-		if err != nil {
-			return errors.WithMessage(err, "could not remove destination directory")
-		}
-	}
+	templates.Setup()
+	loadCSS(ioConfig.Source)
 
-	return build(ioConfig.Destination, *config)
+	return build(ioConfig, cfg, log)
 }
diff --git a/internal/builder/files.go b/internal/builder/files.go
new file mode 100644
index 0000000..a9046d7
--- /dev/null
+++ b/internal/builder/files.go
@@ -0,0 +1,120 @@
+package builder
+
+import (
+	"compress/gzip"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+
+	"github.com/andybalholm/brotli"
+)
+
+const (
+	gzipLevel   = 6
+	brotliLevel = 9
+)
+
+type MultiWriteCloser struct {
+	writers     []io.WriteCloser
+	multiWriter io.Writer
+}
+
+func (mw *MultiWriteCloser) Write(p []byte) (n int, err error) {
+	return mw.multiWriter.Write(p)
+}
+
+func (mw *MultiWriteCloser) Close() error {
+	var lastErr error
+	for _, w := range mw.writers {
+		err := w.Close()
+		if err != nil {
+			lastErr = err
+		}
+	}
+
+	return lastErr
+}
+
+func openFileWrite(filename string) (*os.File, error) {
+	return os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+}
+
+func openFileGz(filename string) (*gzip.Writer, error) {
+	filenameGz := filename + ".gz"
+	f, err := openFileWrite(filenameGz)
+	if err != nil {
+		return nil, err
+	}
+
+	return gzip.NewWriterLevel(f, gzipLevel)
+}
+
+func openFileBrotli(filename string) (*brotli.Writer, error) {
+	filenameBrotli := filename + ".br"
+	f, err := openFileWrite(filenameBrotli)
+	if err != nil {
+		return nil, err
+	}
+
+	return brotli.NewWriterLevel(f, brotliLevel), nil
+}
+
+func multiOpenFile(filename string) (*MultiWriteCloser, error) {
+	r, err := openFileWrite(filename)
+	if err != nil {
+		return nil, err
+	}
+	gz, err := openFileGz(filename)
+	if err != nil {
+		return nil, err
+	}
+	br, err := openFileBrotli(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	return &MultiWriteCloser{
+		writers:     []io.WriteCloser{r, gz, br},
+		multiWriter: io.MultiWriter(r, gz, br),
+	}, nil
+}
+
+func openFileAndVariants(filename string) (io.WriteCloser, error) {
+	if compressFiles {
+		return multiOpenFile(filename)
+	}
+
+	return openFileWrite(filename)
+}
+
+func copyRecursive(src, dst string) error {
+	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return mkdirp(dst, rel)
+		}
+
+		sf, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer sf.Close()
+		df, err := openFileAndVariants(filepath.Join(dst, rel))
+		if err != nil {
+			return err
+		}
+		defer df.Close()
+		if _, err := io.Copy(df, sf); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
diff --git a/internal/builder/hasher.go b/internal/builder/hasher.go
new file mode 100644
index 0000000..f0f9167
--- /dev/null
+++ b/internal/builder/hasher.go
@@ -0,0 +1,13 @@
+package builder
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+)
+
+func hash(s string) string {
+	shasum := sha256.New()
+	shasum.Write([]byte(s))
+
+	return "sha256-" + base64.StdEncoding.EncodeToString(shasum.Sum(nil))
+}
diff --git a/internal/builder/sitemap.go b/internal/builder/sitemap.go
deleted file mode 100644
index 81e3a31..0000000
--- a/internal/builder/sitemap.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package builder
-
-import (
-	"io"
-	"website/internal/config"
-
-	"github.com/snabb/sitemap"
-)
-
-type Sitemap struct {
-	config  *config.Config
-	Sitemap *sitemap.Sitemap
-}
-
-func NewSitemap(cfg config.Config) *Sitemap {
-	return &Sitemap{
-		config:  &cfg,
-		Sitemap: sitemap.New(),
-	}
-}
-
-func (s *Sitemap) Add(u *sitemap.URL) {
-	u.Loc = s.config.BaseURL.JoinPath(u.Loc).String()
-	s.Sitemap.Add(u)
-}
-
-func (s *Sitemap) WriteTo(w io.Writer) (int64, error) {
-	return s.Sitemap.WriteTo(w)
-}
diff --git a/internal/builder/template.go b/internal/builder/template.go
index ab36c85..9f019df 100644
--- a/internal/builder/template.go
+++ b/internal/builder/template.go
@@ -1,55 +1,41 @@
 package builder
 
 import (
+	"bytes"
 	"encoding/xml"
-	"fmt"
 	"io"
-	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
-	"sync"
 	"text/template"
-	"time"
-	"website/internal/atom"
-	"website/internal/config"
-	"website/internal/log"
+
+	"go.alanpearce.eu/website/internal/atom"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
 
 	"github.com/PuerkitoBio/goquery"
-	"github.com/a-h/htmlformat"
 	"github.com/antchfx/xmlquery"
 	"github.com/antchfx/xpath"
-	mapset "github.com/deckarep/golang-set/v2"
-	"github.com/pkg/errors"
-	"golang.org/x/net/html"
+	"gitlab.com/tozd/go/errors"
 )
 
 var (
-	assetsOnce     sync.Once
-	css            string
-	countHTML      *goquery.Document
-	liveReloadHTML *goquery.Document
-	templates      = make(map[string]*os.File)
+	css   string
+	nsMap = map[string]string{
+		"xsl":   "http://www.w3.org/1999/XSL/Transform",
+		"atom":  "http://www.w3.org/2005/Atom",
+		"xhtml": "http://www.w3.org/1999/xhtml",
+	}
 )
 
-func loadTemplate(path string) (file *os.File, err error) {
-	if templates[path] == nil {
-		file, err = os.OpenFile(path, os.O_RDONLY, 0)
-		if err != nil {
-			return nil, errors.Wrapf(err, "could not load template at path %s", path)
-		}
-		templates[path] = file
+func loadCSS(source string) {
+	bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css"))
+	if err != nil {
+		panic(err)
 	}
-	file = templates[path]
-
-	return
+	css = string(bytes)
 }
 
-var (
-	imgOnce     sync.Once
-	img         *goquery.Selection
-	urlTemplate *url.URL
-)
-
 type QuerySelection struct {
 	*goquery.Selection
 }
@@ -68,237 +54,9 @@ func (q *QueryDocument) Find(selector string) *QuerySelection {
 	return &QuerySelection{q.Document.Find(selector)}
 }
 
-func NewDocumentNoScript(r io.Reader) (*goquery.Document, error) {
-	root, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false))
-
-	return goquery.NewDocumentFromNode(root), errors.Wrap(err, "could not parse HTML")
-}
-
-func (root QuerySelection) setImgURL(pageURL string, pageTitle string) QuerySelection {
-	clone := countHTML.Clone()
-	imgOnce.Do(func() {
-		var err error
-		img = clone.Find("img")
-		attr, _ := img.Attr("src")
-		if attr == "" {
-			panic("<img> does not have src attribute")
-		}
-		urlTemplate, err = url.Parse(attr)
-		if err != nil {
-			panic(err.Error())
-		}
-	})
-	q := urlTemplate.Query()
-	urlTemplate.RawQuery = ""
-	q.Set("p", pageURL)
-	q.Set("t", pageTitle)
-	output := urlTemplate.String() + "?" + q.Encode()
-	clone.Find("img").SetAttr("src", output)
-	root.AppendSelection(clone.Find("body").Children())
-
-	return root
-}
-
-func layout(
-	filename string,
-	config config.Config,
-	pageTitle string,
-	pageURL string,
-) (*goquery.Document, error) {
-	html, err := loadTemplate(filename)
-	if err != nil {
-		return nil, err
-	}
-	defer func() {
-		_, err := html.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset template file offset: " + err.Error())
-		}
-	}()
-	assetsOnce.Do(func() {
-		var bytes []byte
-		bytes, err = os.ReadFile("templates/style.css")
-		if err != nil {
-			return
-		}
-		css = string(bytes)
-		countFile, err := os.OpenFile("templates/count.html", os.O_RDONLY, 0)
-		if err != nil {
-			return
-		}
-		defer countFile.Close()
-		countHTML, err = NewDocumentNoScript(countFile)
-		if err != nil {
-			return
-		}
-		if config.InjectLiveReload {
-			liveReloadFile, err := os.OpenFile("templates/dev.html", os.O_RDONLY, 0)
-			if err != nil {
-				return
-			}
-			defer liveReloadFile.Close()
-			liveReloadHTML, err = goquery.NewDocumentFromReader(liveReloadFile)
-			if err != nil {
-				return
-			}
-		}
-	})
-	if err != nil {
-		return nil, errors.Wrap(err, "could not set up layout template")
-	}
-
-	doc, err := NewDocumentFromReader(html)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("html").SetAttr("lang", config.DefaultLanguage)
-	doc.Find("head > link[rel=alternate]").SetAttr("title", config.Title)
-	doc.Find("head > link[rel=canonical]").SetAttr("href", pageURL)
-	doc.Find(".title").SetText(config.Title)
-	doc.Find("title").Add(".p-name").SetText(pageTitle)
-	doc.Find("head > style").SetHtml(css)
-	doc.Find("body").setImgURL(pageURL, pageTitle)
-	if config.InjectLiveReload {
-		doc.Find("body").AppendSelection(liveReloadHTML.Find("body").Clone())
-	}
-	nav := doc.Find("nav")
-	navLink := doc.Find("nav a")
-	nav.Empty()
-	for _, link := range config.Menus["main"] {
-		nav.AppendSelection(navLink.Clone().SetAttr("href", link.URL).SetText(link.Name))
-	}
-
-	return doc.Document, nil
-}
-
-func renderPost(post Post, config config.Config) (r io.Reader, err error) {
-	doc, err := layout("templates/post.html", config, post.PostMatter.Title, post.URL)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author")
-	doc.Find(".h-entry .dt-published").
-		SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)).
-		SetText(
-			post.PostMatter.Date.Format("2006-01-02"),
-		)
-	doc.Find(".h-entry .e-content").SetHtml(post.Content)
-	categories := doc.Find(".h-entry .p-categories")
-	tpl := categories.Find(".p-category").ParentsUntilSelection(categories)
-	tpl.Remove()
-	for _, tag := range post.Taxonomies.Tags {
-		cat := tpl.Clone()
-		cat.Find(".p-category").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag)
-		categories.AppendSelection(cat)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderTags(tags Tags, config config.Config, url string) (io.Reader, error) {
-	doc, err := layout("templates/tags.html", config, config.Title, url)
-	if err != nil {
-		return nil, err
-	}
-	tagList := doc.Find(".tags")
-	tpl := doc.Find(".h-feed")
-	tpl.Remove()
-	for _, tag := range mapset.Sorted(tags) {
-		li := tpl.Clone()
-		li.Find("a").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag)
-		tagList.AppendSelection(li)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderListPage(tag string, config config.Config, posts []Post, url string) (io.Reader, error) {
-	var title string
-	if len(tag) > 0 {
-		title = tag
-	} else {
-		title = config.Title
-	}
-	doc, err := layout("templates/list.html", config, title, url)
-	if err != nil {
-		return nil, err
-	}
-	feed := doc.Find(".h-feed")
-	tpl := feed.Find(".h-entry")
-	tpl.Remove()
-
-	doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author")
-	if tag == "" {
-		doc.Find(".filter").Remove()
-	} else {
-		doc.Find(".filter").Find("h3").SetText("#" + tag)
-	}
-
-	for _, post := range posts {
-		entry := tpl.Clone()
-		entry.Find(".p-name").SetText(post.Title).SetAttr("href", post.URL)
-		entry.Find(".dt-published").
-			SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)).
-			SetText(post.PostMatter.Date.Format("2006-01-02"))
-		feed.AppendSelection(entry)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderHomepage(config config.Config, posts []Post, url string) (io.Reader, error) {
-	_, index, err := getPost("content/_index.md")
-	if err != nil {
-		return nil, err
-	}
-	doc, err := layout("templates/homepage.html", config, config.Title, url)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("body").AddClass("h-card")
-	doc.Find(".title").AddClass("p-name u-url")
-
-	html, err := renderMarkdown(index)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("#content").SetHtml(html)
-
-	feed := doc.Find(".h-feed")
-	tpl := feed.Find(".h-entry")
-	tpl.Remove()
-
-	for _, post := range posts[0:3] {
-		entry := tpl.Clone()
-		entry.Find(".p-name").SetText(post.Title)
-		entry.Find(".u-url").SetAttr("href", post.URL)
-		entry.
-			Find(".dt-published").
-			SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)).
-			SetText(post.PostMatter.Date.Format("2006-01-02"))
-
-		feed.AppendSelection(entry)
-	}
-	doc.Find(".u-email").
-		SetAttr("href", fmt.Sprintf("mailto:%s", config.Email)).
-		SetText(config.Email)
-
-	elsewhere := doc.Find(".elsewhere")
-	linkRelMe := elsewhere.Find(".u-url[rel=me]").ParentsUntil("ul")
-	linkRelMe.Remove()
-
-	for _, link := range config.Menus["me"] {
-		el := linkRelMe.Clone()
-		el.Find("a").SetAttr("href", link.URL).SetText(link.Name)
-		elsewhere.AppendSelection(el)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderRobotsTXT(config config.Config) (io.Reader, error) {
+func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) {
 	r, w := io.Pipe()
-	tpl, err := template.ParseFiles("templates/robots.tmpl")
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl"))
 	if err != nil {
 		return nil, err
 	}
@@ -311,55 +69,36 @@ func renderRobotsTXT(config config.Config) (io.Reader, error) {
 		}
 		w.Close()
 	}()
-	return r, nil
-}
 
-func render404(config config.Config, url string) (io.Reader, error) {
-	doc, err := layout("templates/404.html", config, "404 Not Found", url)
-	if err != nil {
-		return nil, err
-	}
-
-	return renderHTML(doc), nil
+	return r, nil
 }
 
 func renderFeed(
 	title string,
-	config config.Config,
-	posts []Post,
+	config *config.Config,
+	posts []content.Post,
 	specific string,
-) (io.Reader, error) {
-	reader, err := loadTemplate("templates/feed.xml")
+) (io.WriterTo, error) {
+	buf := &bytes.Buffer{}
+	datetime := posts[0].Date.UTC()
+
+	buf.WriteString(xml.Header)
+	err := atom.LinkXSL(buf, "/feed-styles.xsl")
 	if err != nil {
 		return nil, err
 	}
-	defer func() {
-		_, err := reader.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset reader: " + err.Error())
-		}
-	}()
-	doc, err := xmlquery.Parse(reader)
-	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
-	}
-	feed := doc.SelectElement("feed")
-	feed.SelectElement("title").FirstChild.Data = title
-	feed.SelectElement("link").SetAttr("href", config.BaseURL.String())
-	feed.SelectElement("id").FirstChild.Data = atom.MakeTagURI(config, specific)
-	datetime, err := posts[0].Date.UTC().MarshalText()
-	if err != nil {
-		return nil, errors.Wrap(err, "could not convert post date to text")
+	feed := &atom.Feed{
+		Title:   title,
+		Link:    atom.MakeLink(config.BaseURL.URL),
+		ID:      atom.MakeTagURI(config, specific),
+		Updated: datetime,
+		Entries: make([]*atom.FeedEntry, len(posts)),
 	}
-	feed.SelectElement("updated").FirstChild.Data = string(datetime)
-	tpl := feed.SelectElement("entry")
-	xmlquery.RemoveFromTree(tpl)
 
-	for _, post := range posts {
-		fullURL := config.BaseURL.JoinPath(post.URL).String()
-		text, err := xml.MarshalIndent(&atom.FeedEntry{
+	for i, post := range posts {
+		feed.Entries[i] = &atom.FeedEntry{
 			Title:   post.Title,
-			Link:    atom.MakeLink(fullURL),
+			Link:    atom.MakeLink(config.BaseURL.JoinPath(post.URL)),
 			ID:      atom.MakeTagURI(config, post.Basename),
 			Updated: post.Date.UTC(),
 			Summary: post.Description,
@@ -368,80 +107,66 @@ func renderFeed(
 				Content: post.Content,
 				Type:    "html",
 			},
-		}, "  ", "    ")
-		if err != nil {
-			return nil, errors.Wrap(err, "could not marshal xml")
-		}
-		entry, err := xmlquery.ParseWithOptions(
-			strings.NewReader(string(text)),
-			xmlquery.ParserOptions{
-				Decoder: &xmlquery.DecoderOptions{
-					Strict:    false,
-					AutoClose: xml.HTMLAutoClose,
-					Entity:    xml.HTMLEntity,
-				},
-			},
-		)
-		if err != nil {
-			return nil, errors.Wrap(err, "could not parse XML")
 		}
-		xmlquery.AddChild(feed, entry.SelectElement("entry"))
+	}
+	enc := xml.NewEncoder(buf)
+	err = enc.Encode(feed)
+	if err != nil {
+		return nil, err
 	}
 
-	return strings.NewReader(doc.OutputXML(true)), nil
+	return buf, nil
 }
 
-func renderFeedStyles() (io.Reader, error) {
-	reader, err := loadTemplate("templates/feed-styles.xsl")
+func renderFeedStyles(source string) (*strings.Reader, error) {
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl"))
 	if err != nil {
 		return nil, err
 	}
-	defer func() {
-		_, err := reader.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset reader: " + err.Error())
-		}
-	}()
-	nsMap := map[string]string{
-		"xsl":   "http://www.w3.org/1999/XSL/Transform",
-		"atom":  "http://www.w3.org/2005/Atom",
-		"xhtml": "http://www.w3.org/1999/xhtml",
+
+	esc := &strings.Builder{}
+	err = xml.EscapeText(esc, []byte(css))
+	if err != nil {
+		return nil, err
 	}
-	doc, err := xmlquery.Parse(reader)
+
+	w := &strings.Builder{}
+	err = tpl.Execute(w, map[string]interface{}{
+		"css": esc.String(),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return strings.NewReader(w.String()), nil
+}
+
+func getFeedStylesHash(r io.Reader) (string, error) {
+	doc, err := xmlquery.Parse(r)
 	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
+		return "", err
 	}
 	expr, err := xpath.CompileWithNS("//xhtml:style", nsMap)
 	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
+		return "", errors.Wrap(err, "could not parse XPath")
 	}
 	style := xmlquery.QuerySelector(doc, expr)
-	xmlquery.AddChild(style, &xmlquery.Node{
-		Type: xmlquery.TextNode,
-		Data: css,
-	})
 
-	return strings.NewReader(doc.OutputXML(true)), nil
+	return hash(style.InnerText()), nil
 }
 
-func renderHTML(doc *goquery.Document) io.Reader {
-	r, w := io.Pipe()
-
-	go func() {
-		_, err := w.Write([]byte("<!doctype html>\n"))
-		if err != nil {
-			log.Error("error writing doctype", "error", err)
-			w.CloseWithError(err)
-		}
-		err = htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)})
-		if err != nil {
-			log.Error("error rendering html", "error", err)
-			w.CloseWithError(err)
-
-			return
-		}
-		defer w.Close()
-	}()
+func getHTMLStyleHash(filenames ...string) (string, error) {
+	fn := filepath.Join(filenames...)
+	f, err := os.Open(fn)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	doc, err := NewDocumentFromReader(f)
+	if err != nil {
+		return "", err
+	}
+	html := doc.Find("head > style").Text()
 
-	return r
+	return hash(html), nil
 }
diff --git a/internal/config/config.go b/internal/config/config.go
index df69bce..7ccad85 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,10 +3,12 @@ package config
 import (
 	"io/fs"
 	"net/url"
-	"website/internal/log"
+	"path/filepath"
+
+	"go.alanpearce.eu/x/log"
 
 	"github.com/BurntSushi/toml"
-	"github.com/pkg/errors"
+	"gitlab.com/tozd/go/errors"
 )
 
 type Taxonomy struct {
@@ -16,7 +18,7 @@ type Taxonomy struct {
 
 type MenuItem struct {
 	Name string
-	URL  string `toml:"url"`
+	URL  URL `toml:"url"`
 }
 
 type URL struct {
@@ -38,6 +40,9 @@ type Config struct {
 	Description      string
 	DomainStartDate  string `toml:"domain_start_date"`
 	OriginalDomain   string `toml:"original_domain"`
+	GoatCounter      URL    `toml:"goatcounter"`
+	Domains          []string
+	WildcardDomain   string `toml:"wildcard_domain"`
 	OIDCHost         URL    `toml:"oidc_host"`
 	Taxonomies       []Taxonomy
 	CSP              *CSP `toml:"content-security-policy"`
@@ -47,10 +52,11 @@ type Config struct {
 	Menus map[string][]MenuItem
 }
 
-func GetConfig() (*Config, error) {
+func GetConfig(dir string, log *log.Logger) (*Config, error) {
 	config := Config{}
-	log.Debug("reading config.toml")
-	_, err := toml.DecodeFile("config.toml", &config)
+	filename := filepath.Join(dir, "config.toml")
+	log.Debug("reading config", "filename", filename)
+	_, err := toml.DecodeFile(filename, &config)
 	if err != nil {
 		switch t := err.(type) {
 		case *fs.PathError:
diff --git a/internal/config/cspgenerator.go b/internal/config/cspgenerator.go
index 40eca01..9974819 100644
--- a/internal/config/cspgenerator.go
+++ b/internal/config/cspgenerator.go
@@ -9,7 +9,7 @@ import (
 
 	"github.com/crewjam/csp"
 	"github.com/fatih/structtag"
-	"github.com/pkg/errors"
+	"gitlab.com/tozd/go/errors"
 )
 
 func GenerateCSP() error {
diff --git a/internal/builder/posts.go b/internal/content/posts.go
index deae3e8..f4c6c76 100644
--- a/internal/builder/posts.go
+++ b/internal/content/posts.go
@@ -1,4 +1,4 @@
-package builder
+package content
 
 import (
 	"bytes"
@@ -8,15 +8,16 @@ import (
 	"slices"
 	"strings"
 	"time"
-	"website/internal/log"
+
+	"go.alanpearce.eu/x/log"
 
 	"github.com/adrg/frontmatter"
 	mapset "github.com/deckarep/golang-set/v2"
-	"github.com/pkg/errors"
 	fences "github.com/stefanfritsch/goldmark-fences"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/extension"
 	htmlrenderer "github.com/yuin/goldmark/renderer/html"
+	"gitlab.com/tozd/go/errors"
 )
 
 type PostMatter struct {
@@ -51,7 +52,7 @@ var markdown = goldmark.New(
 	),
 )
 
-func getPost(filename string) (*PostMatter, []byte, error) {
+func GetPost(filename string) (*PostMatter, []byte, error) {
 	matter := PostMatter{}
 	content, err := os.Open(filename)
 	if err != nil {
@@ -70,7 +71,7 @@ func getPost(filename string) (*PostMatter, []byte, error) {
 	return &matter, rest, nil
 }
 
-func renderMarkdown(content []byte) (string, error) {
+func RenderMarkdown(content []byte) (string, error) {
 	var buf bytes.Buffer
 	if err := markdown.Convert(content, &buf); err != nil {
 		return "", errors.WithMessage(err, "could not convert markdown content")
@@ -79,23 +80,29 @@ func renderMarkdown(content []byte) (string, error) {
 	return buf.String(), nil
 }
 
-func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, error) {
+type Config struct {
+	Root      string
+	InputDir  string
+	OutputDir string
+}
+
+func ReadPosts(config *Config, log *log.Logger) ([]Post, Tags, error) {
 	tags := mapset.NewSet[string]()
 	posts := []Post{}
-	subdir := filepath.Join(root, inputDir)
+	subdir := filepath.Join(config.Root, config.InputDir)
 	files, err := os.ReadDir(subdir)
 	if err != nil {
 		return nil, nil, errors.WithMessagef(err, "could not read post directory %s", subdir)
 	}
-	outputReplacer := strings.NewReplacer(root, outputDir, ".md", "/index.html")
-	urlReplacer := strings.NewReplacer(root, "", ".md", "/")
+	outputReplacer := strings.NewReplacer(config.Root, config.OutputDir, ".md", "/index.html")
+	urlReplacer := strings.NewReplacer(config.Root, "", ".md", "/")
 	for _, f := range files {
 		pathFromRoot := filepath.Join(subdir, f.Name())
 		if !f.IsDir() && path.Ext(pathFromRoot) == ".md" {
 			output := outputReplacer.Replace(pathFromRoot)
 			url := urlReplacer.Replace(pathFromRoot)
 			log.Debug("reading post", "post", pathFromRoot)
-			matter, content, err := getPost(pathFromRoot)
+			matter, content, err := GetPost(pathFromRoot)
 			if err != nil {
 				return nil, nil, err
 			}
@@ -105,7 +112,7 @@ func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, er
 			}
 
 			log.Debug("rendering markdown in post", "post", pathFromRoot)
-			html, err := renderMarkdown(content)
+			html, err := RenderMarkdown(content)
 			if err != nil {
 				return nil, nil, err
 			}
diff --git a/internal/http/error.go b/internal/http/error.go
new file mode 100644
index 0000000..8ad3e16
--- /dev/null
+++ b/internal/http/error.go
@@ -0,0 +1,7 @@
+package http
+
+type Error struct {
+	Error   error
+	Message string
+	Code    int
+}
diff --git a/internal/log/log.go b/internal/log/log.go
deleted file mode 100644
index e16d7bb..0000000
--- a/internal/log/log.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package log
-
-import (
-	"os"
-
-	zaplogfmt "github.com/sykesm/zap-logfmt"
-	prettyconsole "github.com/thessem/zap-prettyconsole"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
-)
-
-var logger *zap.SugaredLogger
-
-func DPanic(msg string, rest ...any) {
-	logger.DPanicw(msg, rest...)
-}
-func Debug(msg string, rest ...any) {
-	logger.Debugw(msg, rest...)
-}
-func Info(msg string, rest ...any) {
-	logger.Infow(msg, rest...)
-}
-func Warn(msg string, rest ...any) {
-	logger.Warnw(msg, rest...)
-}
-func Error(msg string, rest ...any) {
-	logger.Errorw(msg, rest...)
-}
-func Panic(msg string, rest ...any) {
-	logger.Panicw(msg, rest...)
-}
-func Fatal(msg string, rest ...any) {
-	logger.Fatalw(msg, rest...)
-}
-
-func Configure(isProduction bool) {
-	var l *zap.Logger
-	if isProduction {
-		cfg := zap.NewProductionEncoderConfig()
-		cfg.TimeKey = ""
-		l = zap.New(zapcore.NewCore(zaplogfmt.NewEncoder(cfg), os.Stderr, zapcore.InfoLevel))
-	} else {
-		cfg := prettyconsole.NewConfig()
-		cfg.EncoderConfig.TimeKey = ""
-		l = zap.Must(cfg.Build())
-	}
-	logger = l.WithOptions(zap.AddCallerSkip(1)).Sugar()
-}
diff --git a/internal/server/dev.go b/internal/server/dev.go
index f7ebb82..6fcc93e 100644
--- a/internal/server/dev.go
+++ b/internal/server/dev.go
@@ -3,37 +3,64 @@ package server
 import (
 	"fmt"
 	"io/fs"
-	"log/slog"
 	"os"
+	"path"
 	"path/filepath"
+	"slices"
 	"time"
-	"website/internal/log"
+
+	"go.alanpearce.eu/x/log"
 
 	"github.com/fsnotify/fsnotify"
-	"github.com/pkg/errors"
+	"gitlab.com/tozd/go/errors"
 )
 
 type FileWatcher struct {
 	*fsnotify.Watcher
 }
 
-func NewFileWatcher() (*FileWatcher, error) {
+var (
+	l       *log.Logger
+	ignores = []string{
+		"*.templ",
+		"*.go",
+	}
+	checkSettleInterval = 200 * time.Millisecond
+)
+
+func matches(name string) func(string) bool {
+	return func(pattern string) bool {
+		matched, err := path.Match(pattern, name)
+		if err != nil {
+			l.Warn("error checking watcher ignores", "error", err)
+		}
+
+		return matched
+	}
+}
+
+func ignored(pathname string) bool {
+	return slices.ContainsFunc(ignores, matches(path.Base(pathname)))
+}
+
+func NewFileWatcher(log *log.Logger) (*FileWatcher, error) {
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not create watcher")
 	}
+	l = log
 
 	return &FileWatcher{watcher}, nil
 }
 
 func (watcher FileWatcher) AddRecursive(from string) error {
-	log.Debug("walking directory tree", "root", from)
+	l.Debug("walking directory tree", "root", from)
 	err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
 		if err != nil {
 			return errors.WithMessagef(err, "could not walk directory %s", path)
 		}
 		if entry.IsDir() {
-			log.Debug("adding directory to watcher", "path", path)
+			l.Debug("adding directory to watcher", "path", path)
 			if err = watcher.Add(path); err != nil {
 				return errors.WithMessagef(err, "could not add directory %s to watcher", path)
 			}
@@ -46,26 +73,37 @@ func (watcher FileWatcher) AddRecursive(from string) error {
 }
 
 func (watcher FileWatcher) Start(callback func(string)) {
+	var timer *time.Timer
 	for {
 		select {
 		case event := <-watcher.Events:
-			if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
-				f, err := os.Stat(event.Name)
-				if err != nil {
-					slog.Error(fmt.Sprintf("error handling %s event: %v", event.Op.String(), err))
-				} else if f.IsDir() {
-					err = watcher.Add(event.Name)
+			if !ignored(event.Name) {
+				l.Debug("watcher event", "name", event.Name, "op", event.Op.String())
+				if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
+					f, err := os.Stat(event.Name)
 					if err != nil {
-						slog.Error(fmt.Sprintf("error adding new folder to watcher: %v", err))
+						l.Error(
+							fmt.Sprintf("error handling %s event: %v", event.Op.String(), err),
+						)
+					} else if f.IsDir() {
+						err = watcher.Add(event.Name)
+						if err != nil {
+							l.Error(fmt.Sprintf("error adding new folder to watcher: %v", err))
+						}
 					}
 				}
-			}
-			if event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) {
-				callback(event.Name)
-				time.Sleep(500 * time.Millisecond)
+				if event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) ||
+					event.Has(fsnotify.Create) || event.Has(fsnotify.Chmod) {
+					if timer == nil {
+						timer = time.AfterFunc(checkSettleInterval, func() {
+							callback(event.Name)
+						})
+					}
+					timer.Reset(checkSettleInterval)
+				}
 			}
 		case err := <-watcher.Errors:
-			slog.Error(fmt.Sprintf("error in watcher: %v", err))
+			l.Error("error in watcher", "error", err)
 		}
 	}
 }
diff --git a/internal/server/logging.go b/internal/server/logging.go
index a574bcb..f744931 100644
--- a/internal/server/logging.go
+++ b/internal/server/logging.go
@@ -2,7 +2,8 @@ package server
 
 import (
 	"net/http"
-	"website/internal/log"
+
+	"go.alanpearce.eu/x/log"
 )
 
 type LoggingResponseWriter struct {
@@ -22,25 +23,18 @@ func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter {
 	return &LoggingResponseWriter{w, http.StatusOK}
 }
 
-func wrapHandlerWithLogging(wrappedHandler http.Handler) http.Handler {
+func wrapHandlerWithLogging(wrappedHandler http.Handler, log *log.Logger) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		scheme := r.Header.Get("X-Forwarded-Proto")
-		if scheme == "" {
-			scheme = "http"
-		}
-		host := r.Host
 		lw := NewLoggingResponseWriter(w)
 		wrappedHandler.ServeHTTP(lw, r)
 		if r.URL.Path == "/health" {
 			return
 		}
-		statusCode := lw.statusCode
 		log.Info(
 			"http request",
-			"scheme", scheme,
 			"method", r.Method,
-			"status", statusCode,
-			"host", host,
+			"status", lw.statusCode,
+			"host", r.Host,
 			"path", r.URL.Path,
 			"location", lw.Header().Get("Location"),
 		)
diff --git a/internal/server/mime.go b/internal/server/mime.go
index 696a0ad..cb1b1cf 100644
--- a/internal/server/mime.go
+++ b/internal/server/mime.go
@@ -2,21 +2,18 @@ package server
 
 import (
 	"mime"
-	"website/internal/log"
+
+	"go.alanpearce.eu/x/log"
 )
 
 var newMIMEs = map[string]string{
 	".xsl": "text/xsl",
 }
 
-func fixupMIMETypes() {
+func fixupMIMETypes(log *log.Logger) {
 	for ext, newType := range newMIMEs {
 		if err := mime.AddExtensionType(ext, newType); err != nil {
 			log.Error("could not update mime type", "ext", ext, "mime", newType)
 		}
 	}
 }
-
-func init() {
-	fixupMIMETypes()
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index 77905f8..269ed9e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -7,18 +7,22 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"path/filepath"
+	"regexp"
 	"slices"
+	"strconv"
+	"strings"
 	"time"
 
-	"website/internal/builder"
-	cfg "website/internal/config"
-	"website/internal/log"
-	"website/internal/website"
+	"go.alanpearce.eu/website/internal/builder"
+	cfg "go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/vcs"
+	"go.alanpearce.eu/website/internal/website"
+	"go.alanpearce.eu/x/log"
 
+	"github.com/ardanlabs/conf/v3"
 	"github.com/osdevisnot/sorvor/pkg/livereload"
-	"github.com/pkg/errors"
-	"golang.org/x/net/http2"
-	"golang.org/x/net/http2/h2c"
+	"gitlab.com/tozd/go/errors"
 )
 
 var (
@@ -28,69 +32,129 @@ var (
 )
 
 type Config struct {
-	Development   bool   `conf:"default:false,flag:dev"`
-	Root          string `conf:"default:website"`
+	Root          string `conf:"default:public"`
+	Redirect      bool   `conf:"default:true"`
 	ListenAddress string `conf:"default:localhost"`
-	Port          string `conf:"default:3000,short:p"`
+	Port          int    `conf:"default:8080,short:p"`
+	TLSPort       int    `conf:"default:8443"`
+	TLS           bool   `conf:"default:false"`
+
+	Development bool   `conf:"default:false,flag:dev"`
+	ACMECA      string `conf:"env:ACME_CA"`
+	ACMECACert  string `conf:"env:ACME_CA_CERT"`
+	Domains     string
 }
 
 type Server struct {
 	*http.Server
+	runtimeConfig *Config
+	config        *cfg.Config
+	log           *log.Logger
 }
 
-func applyDevModeOverrides(config *cfg.Config, listenAddress string) {
-	config.CSP.StyleSrc = slices.Insert(config.CSP.StyleSrc, 0, "'unsafe-inline'")
+func applyDevModeOverrides(config *cfg.Config, runtimeConfig *Config) {
 	config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'")
 	config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'")
+	if runtimeConfig.Domains != "" {
+		config.Domains = strings.Split(runtimeConfig.Domains, ",")
+	} else {
+		config.Domains = []string{runtimeConfig.ListenAddress}
+	}
+	scheme := "http"
+	port := runtimeConfig.Port
+	if runtimeConfig.TLS {
+		scheme = "https"
+		port = runtimeConfig.TLSPort
+	}
 	config.BaseURL = cfg.URL{
 		URL: &url.URL{
-			Scheme: "http",
-			Host:   listenAddress,
+			Scheme: scheme,
+			Host:   net.JoinHostPort(config.Domains[0], strconv.Itoa(port)),
 		},
 	}
 }
 
+func updateCSPHashes(config *cfg.Config, r *builder.Result) {
+	for i, h := range r.Hashes {
+		config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h)
+	}
+}
+
 func serverHeaderHandler(wrappedHandler http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.ProtoMajor >= 2 && r.Header.Get("Host") != "" {
-			// net/http does this for HTTP/1.1, but not h2c
-			// TODO: check with HTTP/2.0 (i.e. with TLS)
-			r.Host = r.Header.Get("Host")
-			r.Header.Del("Host")
-		}
 		w.Header().Set("Server", serverHeader)
 		wrappedHandler.ServeHTTP(w, r)
 	})
 }
 
-func New(runtimeConfig *Config) (*Server, error) {
-	var err error
-	config, err := cfg.GetConfig()
+func rebuild(builderConfig *builder.IOConfig, config *cfg.Config, log *log.Logger) error {
+	r, err := builder.BuildSite(builderConfig, config, log.Named("builder"))
+	if err != nil {
+		return errors.WithMessage(err, "could not build site")
+	}
+	updateCSPHashes(config, r)
+
+	return nil
+}
+
+func New(runtimeConfig *Config, log *log.Logger) (*Server, error) {
+	builderConfig := &builder.IOConfig{
+		Destination: runtimeConfig.Root,
+		Development: runtimeConfig.Development,
+	}
+
+	if !runtimeConfig.Development {
+		vcsConfig := &vcs.Config{}
+		_, err := conf.Parse("VCS", vcsConfig)
+		if err != nil {
+			return nil, err
+		}
+		if vcsConfig.LocalPath != "" {
+			_, err = vcs.CloneOrUpdate(vcsConfig, log.Named("vcs"))
+			if err != nil {
+				return nil, err
+			}
+			err = os.Chdir(runtimeConfig.Root)
+			if err != nil {
+				return nil, err
+			}
+
+			builderConfig.Source = vcsConfig.LocalPath
+
+			publicDir := filepath.Join(runtimeConfig.Root, "public")
+			builderConfig.Destination = publicDir
+			runtimeConfig.Root = publicDir
+		} else {
+			log.Warn("in production mode without VCS configuration")
+		}
+	}
+
+	config, err := cfg.GetConfig(builderConfig.Source, log.Named("config"))
 	if err != nil {
 		return nil, errors.WithMessage(err, "error parsing configuration file")
 	}
+	if runtimeConfig.Development {
+		applyDevModeOverrides(config, runtimeConfig)
+	}
 
-	listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, runtimeConfig.Port)
 	top := http.NewServeMux()
 
-	if runtimeConfig.Development {
-		applyDevModeOverrides(config, listenAddress)
-		builderConfig := builder.IOConfig{
-			Source:      "content",
-			Destination: runtimeConfig.Root,
-			BaseURL:     config.BaseURL,
-			Development: true,
-		}
-		builder.BuildSite(builderConfig)
+	err = rebuild(builderConfig, config, log)
+	if err != nil {
+		return nil, err
+	}
+
+	fixupMIMETypes(log)
 
+	if runtimeConfig.Development {
 		liveReload := livereload.New()
 		top.Handle("/_/reload", liveReload)
 		liveReload.Start()
-		fw, err := NewFileWatcher()
+		fw, err := NewFileWatcher(log.Named("watcher"))
 		if err != nil {
 			return nil, errors.WithMessage(err, "could not create file watcher")
 		}
-		for _, dir := range []string{"content", "static", "templates"} {
+		for _, dir := range []string{"content", "static", "templates", "internal/builder"} {
 			err := fw.AddRecursive(dir)
 			if err != nil {
 				return nil, errors.WithMessagef(
@@ -100,62 +164,84 @@ func New(runtimeConfig *Config) (*Server, error) {
 				)
 			}
 		}
+		err = fw.Add(".")
+		if err != nil {
+			return nil, errors.WithMessage(err, "could not add directory to file watcher")
+		}
 		go fw.Start(func(filename string) {
-			log.Debug("file updated", "filename", filename)
-			builder.BuildSite(builderConfig)
-			liveReload.Reload()
+			log.Info("rebuilding site", "changed_file", filename)
+			err := rebuild(builderConfig, config, log)
+			if err != nil {
+				log.Error("error rebuilding site", "error", err)
+			}
 		})
 	}
 
 	loggingMux := http.NewServeMux()
-	mux, err := website.NewMux(config, runtimeConfig.Root)
+	mux, err := website.NewMux(config, runtimeConfig.Root, log.Named("website"))
 	if err != nil {
 		return nil, errors.Wrap(err, "could not create website mux")
 	}
-	log.Debug("binding main handler to", "host", listenAddress)
-	hostname := config.BaseURL.Hostname()
-
-	loggingMux.Handle(hostname+"/", mux)
 
-	loggingMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		newURL := config.BaseURL.JoinPath(r.URL.String())
-		http.Redirect(w, r, newURL.String(), 301)
-	})
+	if runtimeConfig.Redirect {
+		re := regexp.MustCompile(
+			"^(.*)\\." + strings.ReplaceAll(config.WildcardDomain, ".", `\.`) + "$",
+		)
+		replace := "${1}." + config.Domains[0]
+		loggingMux.Handle(config.BaseURL.Hostname()+"/", mux)
+		loggingMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+			if slices.Contains(config.Domains, r.Host) {
+				path, _ := website.CanonicalisePath(r.URL.Path)
+				newURL := config.BaseURL.JoinPath(path)
+				http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
+			} else {
+				url := config.BaseURL
+				url.Host = re.ReplaceAllString(r.Host, replace)
+				http.Redirect(w, r, url.String(), http.StatusTemporaryRedirect)
+			}
+		})
+	} else {
+		loggingMux.Handle("/", mux)
+	}
 
-	top.Handle("/",
-		serverHeaderHandler(
-			wrapHandlerWithLogging(loggingMux),
-		),
-	)
+	if runtimeConfig.Development {
+		top.Handle("/",
+			serverHeaderHandler(
+				wrapHandlerWithLogging(loggingMux, log),
+			),
+		)
+	} else {
+		top.Handle("/", serverHeaderHandler(loggingMux))
+	}
 
 	top.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusNoContent)
 	})
 
 	return &Server{
-		&http.Server{
-			Addr:              listenAddress,
-			ReadHeaderTimeout: 1 * time.Minute,
-			Handler: http.MaxBytesHandler(h2c.NewHandler(
-				top,
-				&http2.Server{
-					IdleTimeout: 15 * time.Minute,
-				},
-			), 0),
+		Server: &http.Server{
+			ReadHeaderTimeout: 10 * time.Second,
+			ReadTimeout:       1 * time.Minute,
+			WriteTimeout:      2 * time.Minute,
+			IdleTimeout:       10 * time.Minute,
+			Handler:           top,
 		},
+		log:           log,
+		config:        config,
+		runtimeConfig: runtimeConfig,
 	}, nil
 }
 
-func (s *Server) Start() error {
-	f := os.NewFile(uintptr(3), "")
-	l, err := net.FileListener(f)
-	if err != nil {
-		l, err = net.Listen("tcp", s.Addr)
-		if err != nil {
-			return errors.Wrap(err, "could not create listener")
-		}
+func (s *Server) serve(tls bool) error {
+	if tls {
+		return s.serveTLS()
 	}
-	if err := http.Serve(l, s.Handler); err != http.ErrServerClosed {
+
+	return s.serveTCP()
+}
+
+func (s *Server) Start() error {
+	if err := s.serve(s.runtimeConfig.TLS); err != http.ErrServerClosed {
 		return errors.Wrap(err, "error creating/closing server")
 	}
 
@@ -163,19 +249,19 @@ func (s *Server) Start() error {
 }
 
 func (s *Server) Stop() chan struct{} {
-	log.Debug("stop called")
+	s.log.Debug("stop called")
 
 	idleConnsClosed := make(chan struct{})
 
 	go func() {
-		log.Debug("shutting down server")
+		s.log.Debug("shutting down server")
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 		defer cancel()
 		err := s.Server.Shutdown(ctx)
-		log.Debug("server shut down")
+		s.log.Debug("server shut down")
 		if err != nil {
 			// Error from closing listeners, or context timeout:
-			log.Warn("HTTP server Shutdown", "error", err)
+			s.log.Warn("HTTP server Shutdown", "error", err)
 		}
 		close(idleConnsClosed)
 	}()
diff --git a/internal/server/tcp.go b/internal/server/tcp.go
new file mode 100644
index 0000000..1627854
--- /dev/null
+++ b/internal/server/tcp.go
@@ -0,0 +1,14 @@
+package server
+
+import (
+	"go.alanpearce.eu/x/listenfd"
+)
+
+func (s *Server) serveTCP() error {
+	l, err := listenfd.GetListener(0, s.Addr, s.log.Named("tcp.listenfd"))
+	if err != nil {
+		return err
+	}
+
+	return s.Serve(l)
+}
diff --git a/internal/server/tls.go b/internal/server/tls.go
new file mode 100644
index 0000000..4d52b8d
--- /dev/null
+++ b/internal/server/tls.go
@@ -0,0 +1,185 @@
+package server
+
+import (
+	"context"
+	"crypto/x509"
+	"net"
+	"net/http"
+	"strconv"
+
+	"go.alanpearce.eu/x/listenfd"
+
+	"github.com/ardanlabs/conf/v3"
+	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/certmagic"
+	"github.com/libdns/acmedns"
+	certmagic_redis "github.com/pberkel/caddy-storage-redis"
+	"gitlab.com/tozd/go/errors"
+)
+
+type redisConfig struct {
+	Address       string `conf:"required"`
+	Username      string `conf:"default:default"`
+	Password      string `conf:"required"`
+	EncryptionKey string `conf:"required"`
+	KeyPrefix     string `conf:"default:certmagic"`
+}
+
+type acmeConfig struct {
+	Username  string `conf:"required"`
+	Password  string `conf:"required"`
+	Subdomain string `conf:"required"`
+	ServerURL string `conf:"env:SERVER_URL,default:https://acme.alanpearce.eu"`
+}
+
+func (s *Server) serveTLS() (err error) {
+	log := s.log.Named("tls")
+
+	// setting cfg.Logger is too late somehow
+	certmagic.Default.Logger = log.GetLogger().Named("certmagic")
+	cfg := certmagic.NewDefault()
+	cfg.DefaultServerName = s.config.Domains[0]
+
+	var issuer *certmagic.ACMEIssuer
+
+	if s.runtimeConfig.Development {
+		ca := s.runtimeConfig.ACMECA
+		if ca == "" {
+			return errors.New("can't enable tls in development without an ACME_CA")
+		}
+
+		cp, err := x509.SystemCertPool()
+		if err != nil {
+			log.Warn("could not get system certificate pool", "error", err)
+			cp = x509.NewCertPool()
+		}
+
+		if cacert := s.runtimeConfig.ACMECACert; cacert != "" {
+			cp.AppendCertsFromPEM([]byte(cacert))
+		}
+
+		// caddy's ACME server (step-ca) doesn't specify an OCSP server
+		cfg.OCSP.DisableStapling = true
+
+		issuer = certmagic.NewACMEIssuer(cfg, certmagic.ACMEIssuer{
+			CA:                      s.runtimeConfig.ACMECA,
+			TrustedRoots:            cp,
+			DisableTLSALPNChallenge: true,
+			ListenHost:              s.runtimeConfig.ListenAddress,
+			AltHTTPPort:             s.runtimeConfig.Port,
+			AltTLSALPNPort:          s.runtimeConfig.TLSPort,
+			Logger:                  certmagic.Default.Logger,
+		})
+	} else {
+		rc := &redisConfig{}
+		_, err = conf.Parse("REDIS", rc)
+		if err != nil {
+			return errors.Wrap(err, "could not parse redis config")
+		}
+
+		acme := &acmedns.Provider{}
+		_, err = conf.Parse("ACME", acme)
+		if err != nil {
+			return errors.Wrap(err, "could not parse ACME config")
+		}
+
+		issuer = certmagic.NewACMEIssuer(cfg, certmagic.ACMEIssuer{
+			CA:     certmagic.LetsEncryptProductionCA,
+			Email:  s.config.Email,
+			Agreed: true,
+			Logger: certmagic.Default.Logger,
+			DNS01Solver: &certmagic.DNS01Solver{
+				DNSManager: certmagic.DNSManager{
+					DNSProvider: acme,
+					Logger:      certmagic.Default.Logger,
+				},
+			},
+		})
+
+		log.Info("acme", "username", acme.Username, "subdomain", acme.Subdomain, "server_url", acme.ServerURL)
+
+		rs := certmagic_redis.New()
+		rs.Address = []string{rc.Address}
+		rs.Username = rc.Username
+		rs.Password = rc.Password
+		rs.EncryptionKey = rc.EncryptionKey
+		rs.KeyPrefix = rc.KeyPrefix
+
+		cfg.Storage = rs
+		err = rs.Provision(caddy.Context{
+			Context: context.Background(),
+		})
+		if err != nil {
+			return errors.Wrap(err, "could not provision redis storage")
+		}
+	}
+	cfg.Issuers[0] = issuer
+
+	ln, err := listenfd.GetListener(
+		1,
+		net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.Port)),
+		log.Named("listenfd"),
+	)
+	if err != nil {
+		return errors.Wrap(err, "could not bind plain socket")
+	}
+
+	go func(ln net.Listener, srv *http.Server) {
+		httpMux := http.NewServeMux()
+		httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+			if certmagic.LooksLikeHTTPChallenge(r) && issuer.HandleHTTPChallenge(w, r) {
+				return
+			}
+			url := r.URL
+			url.Scheme = "https"
+			port := s.config.BaseURL.Port()
+			if port == "" {
+				url.Host = r.Host
+			} else {
+				host, _, err := net.SplitHostPort(r.Host)
+				if err != nil {
+					log.Warn("error splitting host and port", "error", err)
+					host = r.Host
+				}
+				url.Host = net.JoinHostPort(host, s.config.BaseURL.Port())
+			}
+			http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
+		})
+		srv.Handler = httpMux
+
+		if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
+			log.Error("error in http handler", "error", err)
+		}
+	}(ln, &http.Server{
+		ReadHeaderTimeout: s.ReadHeaderTimeout,
+		ReadTimeout:       s.ReadTimeout,
+		WriteTimeout:      s.WriteTimeout,
+		IdleTimeout:       s.IdleTimeout,
+	})
+
+	log.Debug(
+		"starting certmagic",
+		"http_port",
+		s.runtimeConfig.Port,
+		"https_port",
+		s.runtimeConfig.TLSPort,
+	)
+	err = cfg.ManageAsync(context.TODO(), s.config.Domains)
+	if err != nil {
+		return errors.Wrap(err, "could not enable TLS")
+	}
+	tlsConfig := cfg.TLSConfig()
+	tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...)
+
+	sln, err := listenfd.GetListenerTLS(
+		0,
+		net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.TLSPort)),
+		tlsConfig,
+		log.Named("listenfd"),
+	)
+	if err != nil {
+		return errors.Wrap(err, "could not bind tls socket")
+	}
+
+	return s.Serve(sln)
+}
diff --git a/internal/sitemap/sitemap.go b/internal/sitemap/sitemap.go
new file mode 100644
index 0000000..b166f73
--- /dev/null
+++ b/internal/sitemap/sitemap.go
@@ -0,0 +1,36 @@
+package sitemap
+
+import (
+	"io"
+	"time"
+
+	"go.alanpearce.eu/website/internal/config"
+
+	"github.com/snabb/sitemap"
+)
+
+type Sitemap struct {
+	config  *config.Config
+	Sitemap *sitemap.Sitemap
+}
+
+func New(cfg *config.Config) *Sitemap {
+	return &Sitemap{
+		config:  cfg,
+		Sitemap: sitemap.New(),
+	}
+}
+
+func (s *Sitemap) AddPath(path string, lastMod time.Time) {
+	url := &sitemap.URL{
+		Loc: s.config.BaseURL.JoinPath(path).String(),
+	}
+	if !lastMod.IsZero() {
+		url.LastMod = &lastMod
+	}
+	s.Sitemap.Add(url)
+}
+
+func (s *Sitemap) WriteTo(w io.Writer) (int64, error) {
+	return s.Sitemap.WriteTo(w)
+}
diff --git a/internal/vcs/repository.go b/internal/vcs/repository.go
new file mode 100644
index 0000000..5950e53
--- /dev/null
+++ b/internal/vcs/repository.go
@@ -0,0 +1,123 @@
+package vcs
+
+import (
+	"os"
+
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/x/log"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"gitlab.com/tozd/go/errors"
+)
+
+type Config struct {
+	LocalPath string
+	RemoteURL config.URL
+	Branch    string `conf:"default:main"`
+}
+
+type Repository struct {
+	repo *git.Repository
+	log  *log.Logger
+}
+
+func CloneOrUpdate(cfg *Config, log *log.Logger) (*Repository, error) {
+	gr, err := git.PlainClone(cfg.LocalPath, false, &git.CloneOptions{
+		URL:      cfg.RemoteURL.String(),
+		Progress: os.Stdout,
+	})
+	if err != nil {
+		if !errors.Is(err, git.ErrRepositoryAlreadyExists) {
+			return nil, err
+		}
+		gr, err = git.PlainOpen(cfg.LocalPath)
+		if err != nil {
+			return nil, err
+		}
+		repo := &Repository{
+			repo: gr,
+			log:  log,
+		}
+		_, err := repo.Update()
+		if err != nil {
+			return nil, err
+		}
+
+		return repo, nil
+	}
+
+	return &Repository{
+		repo: gr,
+		log:  log,
+	}, nil
+}
+
+func (r *Repository) Update() (bool, error) {
+	r.log.Info("updating repository")
+
+	head, err := r.repo.Head()
+	if err != nil {
+		return false, err
+	}
+
+	r.log.Info("updating from", "rev", head.Hash().String())
+	err = r.repo.Fetch(&git.FetchOptions{
+		Prune: true,
+	})
+	if err != nil {
+		if errors.Is(err, git.NoErrAlreadyUpToDate) {
+			r.log.Info("already up-to-date")
+
+			return true, nil
+		}
+
+		return false, err
+	}
+
+	rem, err := r.repo.Remote("origin")
+	if err != nil {
+		return false, err
+	}
+	refs, err := rem.List(&git.ListOptions{
+		Timeout: 5,
+	})
+
+	var hash plumbing.Hash
+	for _, ref := range refs {
+		if ref.Name() == plumbing.Main {
+			hash = ref.Hash()
+		}
+	}
+
+	wt, err := r.repo.Worktree()
+	if err != nil {
+		return false, err
+	}
+	wt.Checkout(&git.CheckoutOptions{
+		Hash:  hash,
+		Force: true,
+	})
+
+	r.log.Info("updated to", "rev", hash)
+
+	return true, r.Clean(wt)
+}
+
+func (r *Repository) Clean(wt *git.Worktree) error {
+	st, err := wt.Status()
+	if err != nil {
+		return err
+	}
+
+	if !st.IsClean() {
+		err = wt.Clean(&git.CleanOptions{
+			Dir: true,
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/internal/website/filemap.go b/internal/website/filemap.go
index c657848..64b914f 100644
--- a/internal/website/filemap.go
+++ b/internal/website/filemap.go
@@ -5,21 +5,32 @@ import (
 	"hash/fnv"
 	"io"
 	"io/fs"
+	"mime"
 	"os"
 	"path/filepath"
 	"strings"
 
-	"website/internal/log"
+	"go.alanpearce.eu/x/log"
 
-	"github.com/pkg/errors"
+	"gitlab.com/tozd/go/errors"
 )
 
 type File struct {
-	filename string
-	etag     string
+	contentType  string
+	etag         string
+	alternatives map[string]string
 }
 
-var files = map[string]File{}
+func (f *File) AvailableEncodings() []string {
+	encs := []string{}
+	for enc := range f.alternatives {
+		encs = append(encs, enc)
+	}
+
+	return encs
+}
+
+var files = map[string]*File{}
 
 func hashFile(filename string) (string, error) {
 	f, err := os.Open(filename)
@@ -35,25 +46,40 @@ func hashFile(filename string) (string, error) {
 	return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil
 }
 
-func registerFile(urlpath string, filepath string) error {
-	if files[urlpath] != (File{}) {
-		log.Info("registerFile called with duplicate file", "url_path", urlpath)
+var encodings = map[string]string{
+	"br":   ".br",
+	"gzip": ".gz",
+}
 
-		return nil
-	}
-	hash, err := hashFile(filepath)
+func registerFile(urlpath string, fp string) error {
+	hash, err := hashFile(fp)
 	if err != nil {
 		return err
 	}
-	files[urlpath] = File{
-		filename: filepath,
-		etag:     hash,
+	f := File{
+		contentType: mime.TypeByExtension(filepath.Ext(fp)),
+		etag:        hash,
+		alternatives: map[string]string{
+			"identity": fp,
+		},
+	}
+	for enc, suffix := range encodings {
+		_, err := os.Stat(fp + suffix)
+		if err != nil {
+			if errors.Is(err, os.ErrNotExist) {
+				continue
+			}
+
+			return err
+		}
+		f.alternatives[enc] = fp + suffix
 	}
+	files[urlpath] = &f
 
 	return nil
 }
 
-func registerContentFiles(root string) error {
+func registerContentFiles(root string, log *log.Logger) error {
 	err := filepath.WalkDir(root, func(filePath string, f fs.DirEntry, err error) error {
 		if err != nil {
 			return errors.WithMessagef(err, "failed to access path %s", filePath)
@@ -62,11 +88,15 @@ func registerContentFiles(root string) error {
 		if err != nil {
 			return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath)
 		}
-		urlPath, _ := strings.CutSuffix(relPath, "index.html")
+		urlPath, _ := strings.CutSuffix("/"+relPath, "index.html")
 		if !f.IsDir() {
-			log.Debug("registering file", "urlpath", "/"+urlPath)
+			switch filepath.Ext(relPath) {
+			case ".br", ".gz":
+				return nil
+			}
+			log.Debug("registering file", "urlpath", urlPath)
 
-			return registerFile("/"+urlPath, filePath)
+			return registerFile(urlPath, filePath)
 		}
 
 		return nil
@@ -78,6 +108,6 @@ func registerContentFiles(root string) error {
 	return nil
 }
 
-func GetFile(urlPath string) File {
+func GetFile(urlPath string) *File {
 	return files[urlPath]
 }
diff --git a/internal/website/mux.go b/internal/website/mux.go
index 65a7e59..6844551 100644
--- a/internal/website/mux.go
+++ b/internal/website/mux.go
@@ -3,72 +3,85 @@ package website
 import (
 	"encoding/json"
 	"net/http"
-	"path"
 	"strings"
-	"website/internal/config"
-	"website/internal/log"
+
+	"go.alanpearce.eu/website/internal/config"
+	ihttp "go.alanpearce.eu/website/internal/http"
+	"go.alanpearce.eu/x/log"
+	"go.alanpearce.eu/website/templates"
 
 	"github.com/benpate/digit"
-	"github.com/pkg/errors"
+	"github.com/kevinpollet/nego"
+	"gitlab.com/tozd/go/errors"
 )
 
-type HTTPError struct {
-	Error   error
-	Message string
-	Code    int
-}
-
-func canonicalisePath(path string) (cPath string, differs bool) {
+func CanonicalisePath(path string) (cPath string, differs bool) {
 	cPath = path
 	if strings.HasSuffix(path, "/index.html") {
 		cPath, differs = strings.CutSuffix(path, "index.html")
-	} else if !strings.HasSuffix(path, "/") && files[path+"/"] != (File{}) {
+	} else if !strings.HasSuffix(path, "/") && files[path+"/"] != nil {
 		cPath, differs = path+"/", true
 	}
 
 	return cPath, differs
 }
 
-type webHandler func(http.ResponseWriter, *http.Request) *HTTPError
+type webHandler func(http.ResponseWriter, *http.Request) *ihttp.Error
+
+type WrappedWebHandler struct {
+	config  *config.Config
+	handler webHandler
+	log     *log.Logger
+}
+
+func wrapHandler(cfg *config.Config, webHandler webHandler, log *log.Logger) WrappedWebHandler {
+	return WrappedWebHandler{
+		config:  cfg,
+		handler: webHandler,
+		log:     log,
+	}
+}
 
-func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (fn WrappedWebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	defer func() {
 		if fail := recover(); fail != nil {
 			w.WriteHeader(http.StatusInternalServerError)
-			log.Error("runtime panic!", "error", fail)
+			fn.log.Error("runtime panic!", "error", fail)
 		}
 	}()
-	if err := fn(w, r); err != nil {
+	if err := fn.handler(w, r); err != nil {
 		if strings.Contains(r.Header.Get("Accept"), "text/html") {
 			w.WriteHeader(err.Code)
-			notFoundPage := "website/private/404.html"
-			http.ServeFile(w, r, notFoundPage)
+			err := templates.Error(fn.config, r.URL.Path, err).Render(r.Context(), w)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+			}
 		} else {
 			http.Error(w, err.Message, err.Code)
 		}
 	}
 }
 
-func NewMux(cfg *config.Config, root string) (mux *http.ServeMux, err error) {
+func NewMux(cfg *config.Config, root string, log *log.Logger) (mux *http.ServeMux, err error) {
 	mux = &http.ServeMux{}
 
-	prefix := path.Join(root, "public")
-	log.Debug("registering content files", "prefix", prefix)
-	err = registerContentFiles(prefix)
+	log.Debug("registering content files", "root", root)
+	err = registerContentFiles(root, log)
 	if err != nil {
 		return nil, errors.WithMessagef(err, "registering content files")
 	}
+	templates.Setup()
 
-	mux.Handle("/", webHandler(func(w http.ResponseWriter, r *http.Request) *HTTPError {
-		urlPath, shouldRedirect := canonicalisePath(r.URL.Path)
+	mux.Handle("/", wrapHandler(cfg, func(w http.ResponseWriter, r *http.Request) *ihttp.Error {
+		urlPath, shouldRedirect := CanonicalisePath(r.URL.Path)
 		if shouldRedirect {
 			http.Redirect(w, r, urlPath, 302)
 
 			return nil
 		}
 		file := GetFile(urlPath)
-		if file == (File{}) {
-			return &HTTPError{
+		if file == nil {
+			return &ihttp.Error{
 				Message: "File not found",
 				Code:    http.StatusNotFound,
 			}
@@ -79,11 +92,16 @@ func NewMux(cfg *config.Config, root string) (mux *http.ServeMux, err error) {
 		for k, v := range cfg.Extra.Headers {
 			w.Header().Add(k, v)
 		}
-
-		http.ServeFile(w, r, files[urlPath].filename)
+		enc := nego.NegotiateContentEncoding(r, file.AvailableEncodings()...)
+		switch enc {
+		case "br", "gzip":
+			w.Header().Add("Content-Encoding", enc)
+			w.Header().Add("Content-Type", file.contentType)
+		}
+		http.ServeFile(w, r, files[urlPath].alternatives[enc])
 
 		return nil
-	}))
+	}, log))
 
 	var acctResource = "acct:" + cfg.Email
 	me := digit.NewResource(acctResource).
diff --git a/justfile b/justfile
index 22503ee..9c02f31 100755
--- a/justfile
+++ b/justfile
@@ -1,80 +1,37 @@
-#! /usr/bin/env -S nix develop . --command just --justfile
+#!/usr/bin/env cached-nix-shell
+#!nix-shell ci.nix -i "just --justfile"
 
-fly-system := "x86_64-linux"
-fly-registry := "registry.fly.io/alanpearce-eu"
-docker-tag := env_var_or_default("DOCKER_TAG", `date +%Y%m%d%H%M%S` + "-" + `git rev-parse --short HEAD`)
-started-at := `date +%s`
+docker_registry := "registry.fly.io/alanpearce-eu"
+listen_address := env_var_or_default("LISTEN_ADDRESS", "::1")
+tls_port := env_var_or_default("TLS_PORT", "8443")
+port := env_var_or_default("PORT", "8080")
 
 default:
-    @just --list --justfile {{ justfile() }} --unsorted
+	@just --list --justfile {{ justfile() }} --unsorted
 
 clean:
-	rm -r website
-
-check:
-    nix flake check . --print-build-logs
+	rm -fr public
 
 check-licenses:
-    nix run nixpkgs#go-licenses check ./...
+	go-licenses check ./...
 
 check-links:
-	hyperlink website/public
+	hyperlink public --sources content
 
 update-all:
-    go get -u all
-    gomod2nix --outdir nix
-    nix flake update
-
-watch-flake command:
-    watchexec --restart -w flake.nix -w flake.lock direnv exec . {{ command }}
+	npins update
+	go get -u all
 
 build:
-    go run ./cmd/build
-
-nix-build what:
-    nix build .#{{ what }}
+	templ generate
+	go run ./cmd/build
 
 dev:
-	systemfd -s http::3000 -- modd
-
-docker-stream system=(arch() + "-linux"):
-    @nix build --print-out-paths .#docker-stream-{{ system }} | sh
-
-docker-image system=(arch() + "-linux"):
-    nix build .#docker-image-{{ system }}
-
-docker-stream-fly:
-    just docker-stream {{ fly-system }}
-
-docker-image-fly: (docker-image fly-system)
-
-docker-inspect image-path="result":
-    skopeo inspect docker-archive:{{ image-path }}
-
-print-docker-tag:
-    @echo {{ fly-registry }}:{{ docker-tag }}
-
-stream-to-registry :
-    just docker-stream-fly | gzip --fast | skopeo copy --dest-precompute-digests docker-archive:/dev/stdin docker://{{ fly-registry }}:{{ docker-tag }}
-
-push-to-registry:
-    skopeo copy --dest-precompute-digests docker-archive://`readlink -f result`  docker://{{ fly-registry }}:{{ docker-tag }}
-
-deploy-fly registry-and-tag=(fly-registry + ":" + docker-tag):
-    fly deploy --image {{ registry-and-tag }}
-
-deploy-vercel-preview: clean build
-	vercel pull --environment=preview
-	vercel deploy
-
-deploy-vercel: clean build check-links
-	vercel pull --environment=production
-	vercel deploy --prod
-
-deploy-netlify-preview: clean build
-	netlify deploy
+	systemfd -s https::{{ listen_address }}:{{ tls_port }} -s http::{{ listen_address }}:{{ port }} -- modd
 
-deploy-netlify: clean build check-links
-	netlify deploy --prodIfUnlocked
+ci: build check-links
 
-deploy-production: deploy-vercel deploy-netlify
+cd *DEPLOY_FLAGS:
+	fly auth docker
+	templ generate
+	fly deploy --image $(KO_DOCKER_REPO={{ docker_registry }} ko build --bare ./cmd/server) {{ DEPLOY_FLAGS }}
diff --git a/modd.conf b/modd.conf
index 1c46a59..0c73ea6 100644
--- a/modd.conf
+++ b/modd.conf
@@ -1,3 +1,4 @@
-config.toml cmd/server/* "internal/**" {
-    daemon: go run ./cmd/server --dev
+**/*.go !**/*_templ.go {
+  daemon +sigint: templ generate --watch \
+      --cmd="go run ./cmd/server --dev"
 }
diff --git a/netlify.toml b/netlify.toml
deleted file mode 100644
index 85a1c81..0000000
--- a/netlify.toml
+++ /dev/null
@@ -1,33 +0,0 @@
-[build]
-  publish = "website/public"
-
-[[redirects]]
-  from = "/.well-known/openid-configuration"
-  to = "https://id.alanpearce.eu/.well-known/openid-configuration"
-  status = 302
-
-[[headers]]
-  for = "/*"
-  [headers.values]
-    x-content-type-options = "nosniff"
-    x-xss-protection = "1; mode=block"
-    referrer-policy = "strict-origin-when-cross-origin"
-    strict-transport-security = "max-age=63072000; includeSubDomains; preload"
-    cache-control = '''
-      max-age=300,
-      s-maxage=86400,
-      state-while-revalidate
-    '''
-    content-security-policy = '''
-      default-src 'none';
-      img-src 'self' https://gc.zgo.at;
-      object-src 'none';
-      script-src 'self' https://gc.zgo.at;
-      style-src
-      'sha256-DYuGgioh+cRlROdWp15359Pi5I4iDhP2QHeLZ7WL0uU='
-      'sha256-dHnyLX2LnmRFIAOwsOm0FCUVObCfNL0kqAhVUJMjIMk=';
-      form-action 'none';
-      base-uri 'self';
-      connect-src https://alanpearce-eu.goatcounter.com/count;
-      frame-ancestors https://kagi.com;
-'''
diff --git a/nix/default.nix b/nix/default.nix
deleted file mode 100644
index 095b3f7..0000000
--- a/nix/default.nix
+++ /dev/null
@@ -1,101 +0,0 @@
-{ pkgs, self }:
-let
-  version = "unstable";
-  shortSHA = self.shortRev or self.dirtyShortRev;
-  fullSHA = self.rev or self.dirtyRev;
-  mkDocker = type: { server, website }:
-    let
-      PORT = 80;
-    in
-    pkgs.dockerTools.${type} {
-      name = "registry.fly.io/alanpearce-eu";
-      tag = fullSHA;
-      contents = [
-        website
-      ];
-      config = {
-        Cmd = [ "${server}/bin/server" ];
-        Env = [
-          "PRODUCTION=true"
-          "LISTEN_ADDRESS=::"
-          "PORT=${builtins.toString PORT}"
-        ];
-        ExposedPorts = {
-          "${builtins.toString PORT}/tcp" = { };
-        };
-      };
-    };
-  mkDockerStream = mkDocker "streamLayeredImage";
-  mkDockerImage = mkDocker "buildLayeredImage";
-in
-with pkgs; rec {
-  default = server;
-  builder = buildGoApplication {
-    pname = "website-builder";
-    inherit version;
-    CGO_ENABLED = 0;
-    src = with lib.fileset; toSource {
-      root = ./..;
-      fileset = unions [
-        ./../go.mod
-        ./../go.sum
-        ./../cmd/build
-        ./../internal
-      ];
-    };
-    modules = ./gomod2nix.toml;
-    subPackages = [ "cmd/build" ];
-  };
-  website = runCommandLocal "build"
-    {
-      src = with lib.fileset; toSource {
-        root = ./..;
-        fileset = unions [
-          ./../config.toml
-          ./../content
-          ./../static
-          ./../templates
-        ];
-      };
-    } ''
-    ${builder}/bin/build -s $src -d $out
-  '';
-  server = buildGoApplication {
-    pname = "server";
-    inherit version;
-    CGO_ENABLED = 0;
-    src = with lib.fileset; toSource {
-      root = ./..;
-      fileset = unions [
-        ./../go.mod
-        ./../go.sum
-        ./../cmd/server
-        ./../internal
-      ];
-    };
-    modules = ./gomod2nix.toml;
-    subPackages = [ "cmd/server" ];
-    ldflags = [
-      "-s"
-      "-w"
-      "-X"
-      "website/internal/server.CommitSHA=${fullSHA}"
-      "-X"
-      "website/internal/server.ShortSHA=${shortSHA}"
-    ];
-  };
-  docker-stream = mkDockerStream { inherit server website; };
-  docker-stream-aarch64-linux = mkDockerStream {
-    inherit website; server = (self.packages.aarch64-linux.server);
-  };
-  docker-stream-x86_64-linux = mkDockerStream {
-    inherit website; server = (self.packages.x86_64-linux.server);
-  };
-  docker-image = mkDockerImage { inherit server website; };
-  docker-image-aarch64-linux = mkDockerImage {
-    inherit website; server = (self.packages.aarch64-linux.server);
-  };
-  docker-image-x86_64-linux = mkDockerImage {
-    inherit website; server = (self.packages.x86_64-linux.server);
-  };
-}
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
deleted file mode 100644
index 089a876..0000000
--- a/nix/gomod2nix.toml
+++ /dev/null
@@ -1,121 +0,0 @@
-schema = 3
-
-[mod]
-  [mod."github.com/BurntSushi/toml"]
-    version = "v1.3.2"
-    hash = "sha256-FIwyH67KryRWI9Bk4R8s1zFP0IgKR4L66wNQJYQZLeg="
-  [mod."github.com/Code-Hex/dd"]
-    version = "v1.1.0"
-    hash = "sha256-9aoekzjMXuJmR0/7bfu4y3SfcWBgdfYybB7gt4sNKfk="
-  [mod."github.com/PuerkitoBio/goquery"]
-    version = "v1.9.2"
-    hash = "sha256-9T/XF7YzCDt494E40p46BHZVNGnN4Duqlk/HsdbYEnI="
-  [mod."github.com/a-h/htmlformat"]
-    version = "v0.0.0-20240425000139-1244374b2562"
-    hash = "sha256-qvnbf/VCR2s2VmyPaQeHLkpA01MNy1g1U0l9B9maBcE="
-    replaced = "github.com/alanpearce/htmlformat"
-  [mod."github.com/adrg/frontmatter"]
-    version = "v0.2.0"
-    hash = "sha256-WJsVcdCpkIkjqUz5fJOFStZYwQlrcFzQ6+mZatZiimo="
-  [mod."github.com/andybalholm/cascadia"]
-    version = "v1.3.2"
-    hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
-  [mod."github.com/antchfx/xmlquery"]
-    version = "v1.4.0"
-    hash = "sha256-ReWP6CPDvvWUd7vY0qIP4qyxvrotXrx9HXbGbeProP4="
-  [mod."github.com/antchfx/xpath"]
-    version = "v1.3.0"
-    hash = "sha256-SU+Tnf5c9vsDCrY1BVKjqYLhB91xt9oHBS5bicbs2cA="
-  [mod."github.com/ardanlabs/conf/v3"]
-    version = "v3.1.7"
-    hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ="
-  [mod."github.com/aymerick/douceur"]
-    version = "v0.2.0"
-    hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
-  [mod."github.com/benpate/derp"]
-    version = "v0.31.0"
-    hash = "sha256-Q/QxVDgOFbUjPlwKOsbft3R/8vbNGescX/dcuJGRE0Q="
-  [mod."github.com/benpate/digit"]
-    version = "v0.12.0"
-    hash = "sha256-+leG0kKa+DjkYYbE8l5voMJPDPMYNJ1GIGEGp7wjoKs="
-  [mod."github.com/benpate/domain"]
-    version = "v0.2.1"
-    hash = "sha256-nO3mRwQqgWaVReRFXhZlxdo3EGt87Z4EzZBOIPyZJcs="
-  [mod."github.com/benpate/exp"]
-    version = "v0.8.3"
-    hash = "sha256-FHKWQBPK+0JKMbrb+TXB+MVfgzV72DAv4gEhUEhmV/g="
-  [mod."github.com/benpate/remote"]
-    version = "v0.15.0"
-    hash = "sha256-Rh0YlDycwVAy+CsfB62GIsHgLWen4fD9nT6hrwMp3mk="
-  [mod."github.com/benpate/rosetta"]
-    version = "v0.21.0"
-    hash = "sha256-sM1Sgfs4+7Wuyf7T8QfftWTwM7SK/1s9tEg/3tb/RS8="
-  [mod."github.com/crewjam/csp"]
-    version = "v0.0.2"
-    hash = "sha256-4vlGmDdQjPiXmueCV51fJH/hRcG8eqhCi9TENCXjzfA="
-  [mod."github.com/deckarep/golang-set/v2"]
-    version = "v2.6.0"
-    hash = "sha256-ni1XK75Q8iBBmxgoyZTedP4RmrUPzFC4978xB4HKdfs="
-  [mod."github.com/fatih/structtag"]
-    version = "v1.2.0"
-    hash = "sha256-Y2pjiEmMsxfUH8LONU2/f8k1BibOHeLKJmi4uZm/SSU="
-  [mod."github.com/fsnotify/fsnotify"]
-    version = "v1.7.0"
-    hash = "sha256-MdT2rQyQHspPJcx6n9ozkLbsktIOJutOqDuKpNAtoZY="
-  [mod."github.com/golang/groupcache"]
-    version = "v0.0.0-20210331224755-41bb18bfe9da"
-    hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0="
-  [mod."github.com/gorilla/css"]
-    version = "v1.0.1"
-    hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
-  [mod."github.com/microcosm-cc/bluemonday"]
-    version = "v1.0.26"
-    hash = "sha256-ZX4QUWHVEoGBeTHfPcLD5XoiubeO8GhkdqkC4Me8nRE="
-  [mod."github.com/osdevisnot/sorvor"]
-    version = "v0.4.4"
-    hash = "sha256-BhyO7bvwxIdEV+c6Eo1uqahhcgsHiS8nJpg2aT8t+8s="
-  [mod."github.com/otiai10/copy"]
-    version = "v1.14.0"
-    hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A="
-  [mod."github.com/pkg/errors"]
-    version = "v0.9.1"
-    hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
-  [mod."github.com/snabb/diagio"]
-    version = "v1.0.4"
-    hash = "sha256-H2eGPSXv1mpjDSg0ZDNUr6qRouXZhemE7yvbMSLFlw4="
-  [mod."github.com/snabb/sitemap"]
-    version = "v1.0.4"
-    hash = "sha256-5eq8xuyK3H+IhjkHRFdGrmWyUMxzDA7DEwCmqt8zmgc="
-  [mod."github.com/stefanfritsch/goldmark-fences"]
-    version = "v1.0.0"
-    hash = "sha256-Ei+FLtzyHEqz/ZUwHqtQMKHawglcHqcdXmIa8PLvqtc="
-  [mod."github.com/sykesm/zap-logfmt"]
-    version = "v0.0.4"
-    hash = "sha256-KXVFtOU54chusK8AhZrzrvbbNmzq1mNrhs/7OmO+huE="
-  [mod."github.com/thessem/zap-prettyconsole"]
-    version = "v0.4.0"
-    hash = "sha256-SLpCM1OUiLalSwpO9esXMRjExxEL3V875yxsr3MV/LI="
-  [mod."github.com/yuin/goldmark"]
-    version = "v1.7.1"
-    hash = "sha256-3EUgwoZRRs2jNBWSbB0DGNmfBvx7CeAgEwyUdaRaeR4="
-  [mod."go.uber.org/multierr"]
-    version = "v1.11.0"
-    hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0="
-  [mod."go.uber.org/zap"]
-    version = "v1.27.0"
-    hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
-  [mod."golang.org/x/net"]
-    version = "v0.25.0"
-    hash = "sha256-IjFfXLYNj27WLF7vpkZ6mfFXBnp+7QER3OQ0RgjxN54="
-  [mod."golang.org/x/sync"]
-    version = "v0.7.0"
-    hash = "sha256-2ETllEu2GDWoOd/yMkOkLC2hWBpKzbVZ8LhjLu0d2A8="
-  [mod."golang.org/x/sys"]
-    version = "v0.20.0"
-    hash = "sha256-mowlaoG2k4n1c1rApWef5EMiXd3I77CsUi8jPh6pTYA="
-  [mod."golang.org/x/text"]
-    version = "v0.15.0"
-    hash = "sha256-pBnj0AEkfkvZf+3bN7h6epCD2kurw59clDP7yWvxKlk="
-  [mod."gopkg.in/yaml.v2"]
-    version = "v2.4.0"
-    hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0="
diff --git a/npins/default.nix b/npins/default.nix
new file mode 100644
index 0000000..5e7d086
--- /dev/null
+++ b/npins/default.nix
@@ -0,0 +1,80 @@
+# Generated by npins. Do not modify; will be overwritten regularly
+let
+  data = builtins.fromJSON (builtins.readFile ./sources.json);
+  version = data.version;
+
+  mkSource =
+    spec:
+    assert spec ? type;
+    let
+      path =
+        if spec.type == "Git" then
+          mkGitSource spec
+        else if spec.type == "GitRelease" then
+          mkGitSource spec
+        else if spec.type == "PyPi" then
+          mkPyPiSource spec
+        else if spec.type == "Channel" then
+          mkChannelSource spec
+        else
+          builtins.throw "Unknown source type ${spec.type}";
+    in
+    spec // { outPath = path; };
+
+  mkGitSource =
+    {
+      repository,
+      revision,
+      url ? null,
+      hash,
+      branch ? null,
+      ...
+    }:
+    assert repository ? type;
+    # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
+    # In the latter case, there we will always be an url to the tarball
+    if url != null then
+      (builtins.fetchTarball {
+        inherit url;
+        sha256 = hash; # FIXME: check nix version & use SRI hashes
+      })
+    else
+      assert repository.type == "Git";
+      let
+        urlToName =
+          url: rev:
+          let
+            matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
+
+            short = builtins.substring 0 7 rev;
+
+            appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
+          in
+          "${if matched == null then "source" else builtins.head matched}${appendShort}";
+        name = urlToName repository.url revision;
+      in
+      builtins.fetchGit {
+        url = repository.url;
+        rev = revision;
+        inherit name;
+        # hash = hash;
+      };
+
+  mkPyPiSource =
+    { url, hash, ... }:
+    builtins.fetchurl {
+      inherit url;
+      sha256 = hash;
+    };
+
+  mkChannelSource =
+    { url, hash, ... }:
+    builtins.fetchTarball {
+      inherit url;
+      sha256 = hash;
+    };
+in
+if version == 3 then
+  builtins.mapAttrs (_: mkSource) data.pins
+else
+  throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
diff --git a/npins/sources.json b/npins/sources.json
new file mode 100644
index 0000000..f9966ee
--- /dev/null
+++ b/npins/sources.json
@@ -0,0 +1,29 @@
+{
+  "pins": {
+    "nixpkgs": {
+      "type": "Git",
+      "repository": {
+        "type": "GitHub",
+        "owner": "NixOS",
+        "repo": "nixpkgs"
+      },
+      "branch": "nixpkgs-unstable",
+      "revision": "fe866c653c24adf1520628236d4e70bbb2fdd949",
+      "url": "https://github.com/NixOS/nixpkgs/archive/fe866c653c24adf1520628236d4e70bbb2fdd949.tar.gz",
+      "hash": "138a05jrlw1a0x9ixqypl32yw9lkly7s6yjxyq4d434cf59r2x0b"
+    },
+    "pre-commit-hooks": {
+      "type": "Git",
+      "repository": {
+        "type": "GitHub",
+        "owner": "cachix",
+        "repo": "pre-commit-hooks.nix"
+      },
+      "branch": "master",
+      "revision": "4e743a6920eab45e8ba0fbe49dc459f1423a4b74",
+      "url": "https://github.com/cachix/pre-commit-hooks.nix/archive/4e743a6920eab45e8ba0fbe49dc459f1423a4b74.tar.gz",
+      "hash": "0fc69dsn5rhv2zb16c2bfgx84ja8cmn7d7j2mrw3n4m8y611x40g"
+    }
+  },
+  "version": 3
+}
\ No newline at end of file
diff --git a/shell.nix b/shell.nix
index d2c4c45..5ccedea 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,3 +1,25 @@
-{ system ? builtins.currentSystem }:
-
-(builtins.getFlake (toString ./.)).devShells.${system}.default
+{ pkgs ? (
+    let
+      sources = import ./npins;
+    in
+    import sources.nixpkgs { }
+  )
+}:
+let
+  inherit (import ./.) pre-commit-check;
+in
+pkgs.mkShell {
+  inherit (pre-commit-check) shellHook;
+  packages = with pkgs; [
+    go_1_23
+    npins
+    gopls
+    go-licenses
+    gotools
+    templ
+    hyperlink
+    systemfd
+    just
+    modd
+  ];
+}
diff --git a/templates/404.html b/templates/404.html
deleted file mode 100644
index 81b2a54..0000000
--- a/templates/404.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <link
-      rel="alternate"
-      type="application/atom+xml"
-      title=""
-      href="/atom.xml"
-    />
-    <style></style>
-  </head>
-  <body>
-    <a class="skip" href="#main">Skip to main content</a>
-    <header>
-      <h2>
-        <a href="/" class="title">Site title</a>
-      </h2>
-      <nav>
-        <a href="/">Home</a>
-      </nav>
-    </header>
-    <main id="main">
-      <h1>404</h1>
-      <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
-    </main>
-    <footer>
-      Content is
-      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >CC BY 4.0</a
-      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
-      <a href="https://opensource.org/licenses/MIT">MIT</a>
-    </footer>
-  </body>
-</html>
diff --git a/templates/atom.xml b/templates/atom.xml
deleted file mode 100644
index 81c9a76..0000000
--- a/templates/atom.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?>
-<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="{{ lang }}">
-    <title>{{ config.title }}
-    {%- if term %} - {{ term.name }}
-    {%- elif section.title %} - {{ section.title }}
-    {%- endif -%}
-    </title>
-    {%- if config.description %}
-    <subtitle>{{ config.description }}</subtitle>
-    {%- endif %}
-    <link href="{{ feed_url | safe }}" rel="self" type="application/atom+xml"/>
-    <link href="
-      {%- if section -%}
-        {{ section.permalink | escape_xml | safe }}
-      {%- else -%}
-        {{ config.base_url | escape_xml | safe }}
-      {%- endif -%}
-    "/>
-    <generator uri="https://www.getzola.org/">Zola</generator>
-    <updated>{{ last_updated | date(format="%+") }}</updated>
-    <id>{{ feed_url | safe }}</id>
-    {%- for page in pages %}
-    <entry xml:lang="{{ page.lang }}">
-        <title>{{ page.title }}</title>
-        <published>{{ page.date | date(format="%+") }}</published>
-        <updated>{{ page.updated | default(value=page.date) | date(format="%+") }}</updated>
-        <author>
-          <name>
-            {%- if page.authors -%}
-              {{ page.authors[0] }}
-            {%- elif config.author -%}
-              {{ config.author }}
-            {%- else -%}
-              Unknown
-            {%- endif -%}
-          </name>
-        </author>
-        <link rel="alternate" href="{{ page.permalink | safe }}" type="text/html"/>
-        <id>{{ page.permalink | safe }}</id>
-        {% if page.summary %}
-        <summary type="html">{{ page.summary }}</summary>
-        {% else %}
-        <content type="html">{{ page.content }}</content>
-        {% endif %}
-    </entry>
-    {%- endfor %}
-</feed>
diff --git a/templates/count.html b/templates/count.html
deleted file mode 100644
index 46d5ac4..0000000
--- a/templates/count.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<body>
-  <script data-goatcounter="https://alanpearce-eu.goatcounter.com/count"
-        async src="https://gc.zgo.at/count.v4.js" crossorigin="anonymous"
-        integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script>
-  <noscript>
-    <img src="https://alanpearce-eu.goatcounter.com/count?p=/updated-in-template.go" />
-  </noscript>
-</body>
diff --git a/templates/dev.go b/templates/dev.go
new file mode 100644
index 0000000..37a6416
--- /dev/null
+++ b/templates/dev.go
@@ -0,0 +1,9 @@
+//go:build !embed
+
+package templates
+
+import (
+	"os"
+)
+
+var Files = os.DirFS("templates/")
diff --git a/templates/dev.html b/templates/dev.html
deleted file mode 100644
index 0ca383e..0000000
--- a/templates/dev.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<body>
-  <script defer>
-    new EventSource("/_/reload").onmessage = event => {
-      console.log("got message", event)
-      window.location.reload()
-    };
-  </script>
-</body>
diff --git a/templates/embed.go b/templates/embed.go
new file mode 100644
index 0000000..e7e1f18
--- /dev/null
+++ b/templates/embed.go
@@ -0,0 +1,8 @@
+//go:build embed
+
+package templates
+
+import "embed"
+
+//go:embed *
+var Files embed.FS
diff --git a/templates/error.templ b/templates/error.templ
new file mode 100644
index 0000000..369cb83
--- /dev/null
+++ b/templates/error.templ
@@ -0,0 +1,17 @@
+package templates
+
+import (
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/http"
+	"strconv"
+)
+
+templ Error(config *config.Config, path string, err *http.Error) {
+	@Page(config, PageSettings{
+		Title: "Error",
+		Path:  path,
+	}) {
+		<h1>{ strconv.Itoa(err.Code) } { err.Message }</h1>
+		<h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
+	}
+}
diff --git a/templates/feed-styles.xsl b/templates/feed-styles.xsl
index fcca39d..679d064 100644
--- a/templates/feed-styles.xsl
+++ b/templates/feed-styles.xsl
@@ -12,7 +12,9 @@
         <meta charset="utf-8" />
         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1" />
-        <style></style>
+        <style>
+          {{ .css }}
+        </style>
       </head>
       <body>
         <main>
diff --git a/templates/feed.xml b/templates/feed.xml
deleted file mode 100644
index ddc90dd..0000000
--- a/templates/feed.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?>
-<feed xmlns="http://www.w3.org/2005/Atom">
-  <title>Example Feed</title>
-  <link href="http://example.org/"></link>
-  <id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
-  <updated>2003-12-13T18:30:02Z</updated>
-  <entry>
-    <title>Atom-Powered Robots Run Amok</title>
-    <link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"></link>
-    <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
-    <updated>2003-12-13T18:30:02Z</updated>
-    <summary>Some text.</summary>
-    <content type="html">
-      <div>
-        <p>This is the entry content.</p>
-      </div>
-    </content>
-    <author>
-      <name>John Doe</name> 
-    </author>
-  </entry>
-
-</feed>
diff --git a/templates/homepage.html b/templates/homepage.html
deleted file mode 100644
index 60bedb8..0000000
--- a/templates/homepage.html
+++ /dev/null
@@ -1,63 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <link
-      rel="alternate"
-      type="application/atom+xml"
-      title=""
-      href="/atom.xml"
-    />
-    <link href="" rel="canonical" />
-    <style></style>
-  </head>
-  <body>
-    <a class="skip" href="#main">Skip to main content</a>
-    <header>
-      <h2>
-        <a href="/" class="title">Site title</a>
-      </h2>
-      <nav>
-        <a href="/">Home</a>
-      </nav>
-    </header>
-    <main id="main">
-      <div id="content"></div>
-      <section>
-        <h2>Latest Posts</h2>
-        <ul class="h-feed">
-          <li class="h-entry">
-            <span>
-              <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
-                2000-12-31
-              </time>
-            </span>
-            <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
-          </li>
-        </ul>
-      </section>
-      <section>
-        <h2>Elsewhere on the Internet</h2>
-        <ul class="elsewhere">
-          <li>
-            <a class="u-email" rel="me" href="mailto:user@example.com"
-              >user@example.com</a
-            >
-          </li>
-          <li>
-            <a class="u-url" rel="me" href="http://example.com">Example</a>
-          </li>
-        </ul>
-      </section>
-    </main>
-    <footer>
-      Content is
-      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >CC BY 4.0</a
-      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
-      <a href="https://opensource.org/licenses/MIT">MIT</a>
-    </footer>
-  </body>
-</html>
diff --git a/templates/homepage.templ b/templates/homepage.templ
new file mode 100644
index 0000000..aa61c40
--- /dev/null
+++ b/templates/homepage.templ
@@ -0,0 +1,42 @@
+package templates
+
+import (
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+)
+
+templ Homepage(config *config.Config, posts []content.Post, content string) {
+	@Page(config, PageSettings{
+		Title: config.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-name u-url",
+		},
+		Path: "/",
+		BodyAttrs: templ.Attributes{
+			"class": "h-card",
+		},
+	}) {
+		<div id="content">
+			@Unsafe(content)
+		</div>
+		<section>
+			<h2>Latest Posts</h2>
+			@list(posts[0:3])
+		</section>
+		<section>
+			<h2>Elsewhere on the Internet</h2>
+			<ul class="elsewhere">
+				<li>
+					<a class="u-email" rel="me" href={ templ.SafeURL("mailto:" + config.Email) }>
+						{ config.Email }
+					</a>
+				</li>
+				for _, link := range config.Menus["me"] {
+					<li>
+						<a class="u-url" rel="me" href={ templ.SafeURL(link.URL.String()) }>{ link.Name }</a>
+					</li>
+				}
+			</ul>
+		</section>
+	}
+}
diff --git a/templates/list.html b/templates/list.html
deleted file mode 100644
index 1c0b32b..0000000
--- a/templates/list.html
+++ /dev/null
@@ -1,52 +0,0 @@
-<!doctype html>
-<html lang="en-GB">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <link
-      rel="alternate"
-      type="application/atom+xml"
-      title="Site Title"
-      href="/atom.xml"
-    />
-    <link href="" rel="canonical" />
-    <style></style>
-  </head>
-  <body>
-    <a class="skip" href="#content">Skip to main content</a>
-    <header>
-      <h2>
-        <a href="/" class="title">Site Title</a>
-      </h2>
-      <nav>
-        <a href="/">Home</a>
-      </nav>
-    </header>
-    <main id="content">
-      <div class="filter">
-        <h3 class="filter">Tag</h3>
-        <small>
-          <a href="../">Remove filter</a>
-        </small>
-      </div>
-      <ul class="h-feed">
-        <li class="h-entry">
-          <span>
-            <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
-              2000-12-31
-            </time>
-          </span>
-          <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
-        </li>
-      </ul>
-    </main>
-    <footer>
-      Content is
-      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >CC BY 4.0</a
-      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
-      <a href="https://opensource.org/licenses/MIT">MIT</a>
-    </footer>
-  </body>
-</html>
diff --git a/templates/list.templ b/templates/list.templ
new file mode 100644
index 0000000..fc59677
--- /dev/null
+++ b/templates/list.templ
@@ -0,0 +1,51 @@
+package templates
+
+import (
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+)
+
+templ TagPage(config *config.Config, tag string, posts []content.Post, path string) {
+	@Page(config, PageSettings{
+		Title: tag,
+		Path:  path,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+	}) {
+		<div class="filter">
+			<h3 class="filter">#{ tag }</h3>
+			<small>
+				<a href="../">Remove filter</a>
+			</small>
+		</div>
+		@list(posts)
+	}
+}
+
+templ ListPage(config *config.Config, posts []content.Post, path string) {
+	@Page(config, PageSettings{
+		Title: config.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+		Path: path,
+	}) {
+		@list(posts)
+	}
+}
+
+templ list(posts []content.Post) {
+	<ul class="h-feed">
+		for _, post := range posts {
+			<li class="h-entry">
+				<span>
+					@postDate(post.Date)
+				</span>
+				<a class="p-name u-url" href={ templ.SafeURL(post.URL) }>{ post.Title }</a>
+			</li>
+		}
+	</ul>
+}
diff --git a/templates/page.templ b/templates/page.templ
new file mode 100644
index 0000000..39dd263
--- /dev/null
+++ b/templates/page.templ
@@ -0,0 +1,115 @@
+package templates
+
+import (
+	"io/fs"
+
+	"go.alanpearce.eu/website/internal/config"
+	"net/url"
+)
+
+var (
+	css string
+)
+
+func Setup() {
+	bytes, err := fs.ReadFile(Files, "style.css")
+	if err != nil {
+		panic(err)
+	}
+	css = string(bytes)
+}
+
+type PageSettings struct {
+	Title      string
+	Path       string
+	TitleAttrs templ.Attributes
+	BodyAttrs  templ.Attributes
+}
+
+func extendClasses(cs string, attrs templ.Attributes) string {
+	if extras, exists := attrs["class"]; exists {
+		return templ.Classes(cs, extras).String()
+	} else {
+		return cs
+	}
+}
+
+templ menuItem(item config.MenuItem) {
+	<a
+		href={ templ.SafeURL(item.URL.String()) }
+		if item.URL.IsAbs() {
+			target="_blank"
+		}
+	>{ item.Name }</a>
+}
+
+templ Page(site *config.Config, page PageSettings) {
+	<!DOCTYPE html>
+	<html lang={ site.DefaultLanguage }>
+		<head>
+			<meta charset="utf-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+			<title>{ page.Title }</title>
+			<link rel="alternate" type="application/atom+xml" title={ site.Title } href="/atom.xml"/>
+			@style(css)
+		</head>
+		<body { page.BodyAttrs... }>
+			<a class="skip" href="#main">Skip to main content</a>
+			<header>
+				<h2>
+					<a href="/" class={ extendClasses("title p-name", page.TitleAttrs) } { page.TitleAttrs... }>{ site.Title }</a>
+				</h2>
+				<nav>
+					for _, item := range site.Menus["main"] {
+						@menuItem(item)
+					}
+				</nav>
+			</header>
+			<main id="main">
+				{ children... }
+			</main>
+			<footer>
+				Content is
+				<a rel="license" href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.
+				<a href="https://git.alanpearce.eu/website/">Site source code</a> is
+				<a href="https://opensource.org/licenses/MIT">MIT</a>
+			</footer>
+			@counter(site, page.Path, page.Title)
+			if site.InjectLiveReload {
+				<script defer>
+					new EventSource("/_/reload").onmessage = event => {
+						console.log("got message", event)
+						window.location.reload()
+					};
+				</script>
+			}
+		</body>
+	</html>
+}
+
+func mkURL(original config.URL, path string, title string) string {
+	ou := *original.URL
+	u := config.URL{
+		URL: &ou,
+	}
+	q := url.Values{}
+	q.Add("p", path)
+	q.Add("t", title)
+	u.RawQuery = q.Encode()
+
+	return u.String()
+}
+
+templ counter(config *config.Config, path string, title string) {
+	<script data-goatcounter={ config.GoatCounter.String() } async src="https://stats.alanpearce.eu/count.v4.js" crossorigin="anonymous" integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script>
+	<noscript>
+		<img src={ string(templ.URL(mkURL(config.GoatCounter, path, title))) }/>
+	</noscript>
+}
+
+func style(css string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+		_, err = io.WriteString(w, "<style>\n"+css+"\n</style>")
+		return
+	})
+}
diff --git a/templates/post.html b/templates/post.html
deleted file mode 100644
index 3dad16c..0000000
--- a/templates/post.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<!doctype html>
-<html lang="en-GB">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title></title>
-    <link
-      rel="alternate"
-      type="application/atom+xml"
-      title=""
-      href="/atom.xml"
-    />
-    <link href="" rel="canonical" />
-    <style></style>
-  </head>
-  <body>
-    <a class="skip" href="#main">Skip to main content</a>
-    <header>
-      <h2>
-        <a href="/" class="title"></a>
-      </h2>
-      <nav>
-        <a href="/">Home</a>
-      </nav>
-    </header>
-    <main id="main">
-      <article class="h-entry">
-        <h1 class="p-name">Post Title</h1>
-        <p>
-          <time class="dt-published">2000-12-31</time>
-        </p>
-        <div class="e-content">
-          Enim lobortis scelerisque fermentum dui faucibus in ornare quam
-          viverra. Eget egestas purus viverra accumsan in nisl nisi, scelerisque
-          eu ultrices vitae, auctor eu augue ut lectus arcu, bibendum at.
-
-          <code>/bin/test</code>
-
-          <pre>
-            <code class="language-conf">
-foo=bar
-            </code>
-          </pre>
-
-          <table>
-            <thead>
-              <tr>
-                <th>One</th>
-                <th>Two</th>
-                <th>Three</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <td>1</td>
-                <td>2</td>
-                <td>3</td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-        <div class="tags">
-          Tags:
-          <ul class="p-categories tags">
-            <li><a class="p-category" href="/tags/sample/">#sample</a></li>
-          </ul>
-        </div>
-      </article>
-    </main>
-    <footer>
-      Content is
-      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >CC BY 4.0</a
-      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
-      <a href="https://opensource.org/licenses/MIT">MIT</a>
-    </footer>
-  </body>
-</html>
diff --git a/templates/post.templ b/templates/post.templ
new file mode 100644
index 0000000..9717b4e
--- /dev/null
+++ b/templates/post.templ
@@ -0,0 +1,56 @@
+package templates
+
+import (
+	"time"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+)
+
+func Unsafe(html string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+		_, err = io.WriteString(w, html)
+		return
+	})
+}
+
+templ postDate(d time.Time) {
+	<time class="dt-published" datetime={ d.UTC().Format(time.RFC3339) }>
+		{ d.Format("2006-01-02") }
+	</time>
+}
+
+templ PostPage(config *config.Config, post content.Post) {
+	@Page(config, PageSettings{
+		Title: post.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+		BodyAttrs: templ.Attributes{
+			"class": "h-entry",
+		},
+		Path: post.URL,
+	}) {
+		<article>
+			<h1 class="p-name">{ post.Title }</h1>
+			<p>
+				<a class="u-url" href={ templ.SafeURL(post.URL) }>
+					@postDate(post.Date)
+				</a>
+			</p>
+			<div class="e-content">
+				@Unsafe(post.Content)
+			</div>
+			<div class="tags">
+				Tags:
+				<ul class="p-categories tags">
+					for _, tag := range post.Taxonomies.Tags {
+						<li>
+							@tagLink(tag, templ.Attributes{"class": "p-category"})
+						</li>
+					}
+				</ul>
+			</div>
+		</article>
+	}
+}
diff --git a/templates/style.css b/templates/style.css
index b386843..e9a2955 100644
--- a/templates/style.css
+++ b/templates/style.css
@@ -188,16 +188,17 @@ ul.h-feed li a:visited {
 
 .tags {
   font-size: small;
-}
-
-.p-categories,
-ul.tags {
   display: inline-block;
   padding-inline-start: 0;
 }
+.p-categories {
+  padding-inline-start: 1ex;
+}
+
 .tags li {
   list-style: none;
   display: inline-block;
+  margin-inline-end: 1ex;
 }
 
 svg.rss-icon {
diff --git a/templates/tags.html b/templates/tags.html
deleted file mode 100644
index 1ff18c0..0000000
--- a/templates/tags.html
+++ /dev/null
@@ -1,42 +0,0 @@
-<!doctype html>
-<html lang="en-GB">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <link
-      rel="alternate"
-      type="application/atom+xml"
-      title="Site title"
-      href="/atom.xml"
-    />
-    <link href="" rel="canonical" />
-    <style></style>
-  </head>
-  <body>
-    <a class="skip" href="#content">Skip to main content</a>
-    <header>
-      <h2>
-        <a href="/" class="title">Site title</a>
-      </h2>
-      <nav>
-        <a href="/">Home</a>
-      </nav>
-    </header>
-    <main id="content">
-      <h3 class="filter">Tags</h3>
-      <ul class="tags">
-        <li class="h-feed">
-          <a href="/tags/tag">#tag</a>
-        </li>
-      </ul>
-    </main>
-    <footer>
-      Content is
-      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >CC BY 4.0</a
-      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
-      <a href="https://opensource.org/licenses/MIT">MIT</a>
-    </footer>
-  </body>
-</html>
diff --git a/templates/tags.templ b/templates/tags.templ
new file mode 100644
index 0000000..c872a0d
--- /dev/null
+++ b/templates/tags.templ
@@ -0,0 +1,23 @@
+package templates
+
+import "go.alanpearce.eu/website/internal/config"
+
+templ tagLink(tag string, attrs templ.Attributes) {
+	<a { attrs... } href={ templ.SafeURL("/tags/" + tag) }>#{ tag }</a>
+}
+
+templ TagsPage(config *config.Config, title string, tags []string, path string) {
+	@Page(config, PageSettings{
+		Title: title,
+		Path:  path,
+	}) {
+		<h3 class="filter">Tags</h3>
+		<ul class="tags">
+			for _, tag := range tags {
+				<li class="h-feed">
+					@tagLink(tag, templ.Attributes{})
+				</li>
+			}
+		</ul>
+	}
+}
diff --git a/vercel.json b/vercel.json
deleted file mode 100644
index fac78db..0000000
--- a/vercel.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
-  "$schema": "https://openapi.vercel.sh/vercel.json",
-  "buildCommand": null,
-  "framework": null,
-  "outputDirectory": "website/public",
-  "cleanUrls": false,
-  "trailingSlash": false,
-  "redirects": [
-    {
-      "source": "/.well-known/openid-configuration",
-      "destination": "https://id.alanpearce.eu/.well-known/openid-configuration",
-      "permanent": false
-    }
-  ],
-  "headers": [
-    {
-      "source": "/.well-known/webfinger",
-      "headers": [
-        {
-          "key": "Content-Type",
-          "value": "application/jrd+json"
-        }
-      ]
-    },
-    {
-      "source": "/(.*)",
-      "headers": [
-        {
-          "key": "X-Frame-Options",
-          "value": "DENY"
-        },
-        {
-          "key": "X-Content-Type-Options",
-          "value": "nosniff"
-        },
-        {
-          "key": "X-XSS-Protection",
-          "value": "1; mode=block"
-        },
-        {
-          "key": "Referrer-Policy",
-          "value": "strict-origin-when-cross-origin"
-        },
-        {
-          "key": "Strict-Transport-Security",
-          "value": "max-age=63072000; includeSubDomains; preload"
-        },
-        {
-          "key": "Content-Security-Policy",
-          "value": "default-src 'none'; img-src 'self' https://gc.zgo.at; object-src 'none'; script-src https://gc.zgo.at; style-src 'sha256-DYuGgioh+cRlROdWp15359Pi5I4iDhP2QHeLZ7WL0uU=' 'sha256-dHnyLX2LnmRFIAOwsOm0FCUVObCfNL0kqAhVUJMjIMk='; form-action 'none'; base-uri 'self'; frame-ancestors https://kagi.com; connect-src https://alanpearce-eu.goatcounter.com/count"
-        },
-        {
-          "key": "Cache-Control",
-          "value": "max-age=14400, s-maxage=86400, state-while-revalidate"
-        }
-      ]
-    }
-  ]
-}