about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.envrc6
-rw-r--r--.gitignore33
-rw-r--r--.gitlab-ci.yml23
-rw-r--r--LICENSE19
-rw-r--r--README.md8
-rw-r--r--_redirects1
-rw-r--r--cmd/build/main.go56
-rw-r--r--cmd/cspgenerator/cspgenerator.go13
-rw-r--r--cmd/dev/main.go313
-rw-r--r--cmd/server/main.go71
-rw-r--r--config.toml83
-rw-r--r--content/LICENSE395
-rw-r--r--content/_index.md6
-rw-r--r--content/post/_index.md6
-rw-r--r--content/post/homesteading.md13
-rw-r--r--content/post/nixos-on-nanopi-r5s.md142
-rw-r--r--content/post/now-on-three-continents.md25
-rw-r--r--content/post/repository-management-with-ghq.md6
-rw-r--r--content/post/self-hosted-git.md2
-rw-r--r--default.nix10
-rw-r--r--flake.lock102
-rw-r--r--flake.nix59
-rw-r--r--fly.toml41
-rw-r--r--go.mod49
-rw-r--r--go.sum321
-rw-r--r--internal/atom/atom.go43
-rw-r--r--internal/builder/builder.go196
-rw-r--r--internal/builder/posts.go121
-rw-r--r--internal/builder/template.go371
-rw-r--r--internal/config/config.go73
-rw-r--r--internal/config/csp.go45
-rw-r--r--internal/config/cspgenerator.go79
-rw-r--r--internal/server/filemap.go77
-rw-r--r--internal/server/logging.go55
-rw-r--r--internal/server/server.go226
-rwxr-xr-xjustfile83
-rw-r--r--netlify/netlify.toml14
-rw-r--r--nix/default.nix102
-rw-r--r--nix/gomod2nix.toml118
-rw-r--r--shell.nix3
-rw-r--r--static/cv/index.html348
-rw-r--r--static/robots.txt3
-rw-r--r--templates/404.html38
-rw-r--r--templates/atom.xml48
-rw-r--r--templates/count.html6
-rw-r--r--templates/dev.html8
-rw-r--r--templates/feed-styles.xsl82
-rw-r--r--templates/feed.xml24
-rw-r--r--templates/homepage.html64
-rw-r--r--templates/list.html53
-rw-r--r--templates/post.html79
-rw-r--r--templates/style.css195
-rw-r--r--templates/tags.html43
-rw-r--r--themes/xmin/static/css/style.css75
-rw-r--r--themes/xmin/templates/base.html25
l---------themes/xmin/templates/categories/list.html1
l---------themes/xmin/templates/categories/single.html1
-rw-r--r--themes/xmin/templates/index.html36
-rw-r--r--themes/xmin/templates/page.html28
-rw-r--r--themes/xmin/templates/section.html18
-rw-r--r--themes/xmin/templates/tags/list.html18
-rw-r--r--themes/xmin/templates/tags/single.html25
-rw-r--r--themes/xmin/theme.toml12
63 files changed, 4329 insertions, 310 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..8075d5b
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,6 @@
+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
+fi
diff --git a/.gitignore b/.gitignore
index a959665..2932e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,31 @@
-/public/
+# Allowlisting gitignore template for GO projects prevents us
+# from adding various unwanted local files, such as generated
+# files, developer configurations or IDE-specific files etc.
+#
+# Recommended: Go.AllowList.gitignore
 
-# Local Netlify folder
-.netlify
\ No newline at end of file
+# Ignore everything
+*
+
+# But not these files...
+!.gitignore
+
+!*.go
+!go.sum
+!go.mod
+
+!README.md
+!LICENSE
+
+!.envrc
+!justfile
+!*.nix
+!*.toml
+!/flake.lock
+
+!/content/**/*.md
+!/static/**/*
+!/templates/*
+
+# ...even if they are in subdirectories
+!*/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index 19a279c..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,23 +0,0 @@
----
-variables:
-  # This variable will ensure that the CI runner pulls in your theme from the submodule
-  GIT_SUBMODULE_STRATEGY: recursive
-
-image: nixery.dev/shell/gnugrep/git/zola
-
-test:
-  script:
-    - zola
-  except:
-    variables:
-      - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-
-pages:
-  script:
-    - zola build
-  artifacts:
-    paths:
-      - public
-  only:
-    variables:
-      - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..edbe912
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2024 Alan Pearce <alan@alanpearce.eu>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bc27bc4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# homestead
+
+## Goals
+
+1. Static web server with prometheus-based analytics
+2. Dynamic web server capable of generating Zola-based websites
+3. More indieweb features
+
diff --git a/_redirects b/_redirects
deleted file mode 100644
index 2ee3842..0000000
--- a/_redirects
+++ /dev/null
@@ -1 +0,0 @@
-/post/index.xml /atom.xml 301
\ No newline at end of file
diff --git a/cmd/build/main.go b/cmd/build/main.go
new file mode 100644
index 0000000..069f9bd
--- /dev/null
+++ b/cmd/build/main.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+	"fmt"
+	"io/fs"
+	"log"
+	"log/slog"
+	"os"
+
+	"website/internal/builder"
+
+	"github.com/BurntSushi/toml"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("build: ")
+	slog.Debug("starting build process")
+
+	ioConfig := builder.IOConfig{}
+	if help, err := conf.Parse("", &ioConfig); err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing I/O configuration: %v", err)
+	}
+
+	if ioConfig.Source != "." {
+		err := os.Chdir(ioConfig.Source)
+		if err != nil {
+			log.Panic("could not change to source directory")
+		}
+	}
+
+	if err := builder.BuildSite(ioConfig); err != nil {
+		switch cause := errors.Cause(err).(type) {
+		case *fs.PathError:
+			slog.Info("pathError")
+			slog.Error(fmt.Sprintf("%s", err))
+		case toml.ParseError:
+			slog.Info("parseError")
+			slog.Error(fmt.Sprintf("%s", err))
+		default:
+			slog.Info("other")
+			slog.Error(fmt.Sprintf("cause:%+v", errors.Cause(cause)))
+			slog.Error(fmt.Sprintf("%+v", cause))
+		}
+		os.Exit(1)
+	}
+}
diff --git a/cmd/cspgenerator/cspgenerator.go b/cmd/cspgenerator/cspgenerator.go
new file mode 100644
index 0000000..f79a591
--- /dev/null
+++ b/cmd/cspgenerator/cspgenerator.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"log"
+	"website/internal/config"
+)
+
+func main() {
+	err := config.GenerateCSP()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/cmd/dev/main.go b/cmd/dev/main.go
new file mode 100644
index 0000000..1a6ccea
--- /dev/null
+++ b/cmd/dev/main.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"io/fs"
+	"log"
+	"log/slog"
+	"net/http"
+	"net/http/httputil"
+
+	"os"
+	"os/exec"
+	"os/signal"
+	"path"
+	"path/filepath"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"website/internal/config"
+
+	"github.com/antage/eventsource"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/gohugoio/hugo/watcher"
+
+	"github.com/pkg/errors"
+)
+
+type DevConfig struct {
+	Source    string     `conf:"default:.,short:s"`
+	TempDir   string     `conf:"required,short:t"`
+	BaseURL   config.URL `conf:"default:http://localhost:3000"`
+	ServerURL config.URL `conf:"default:http://localhost:3001"`
+}
+
+func RunCommandPiped(ctx context.Context, command string, args ...string) (cmd *exec.Cmd, err error) {
+	slog.Debug(fmt.Sprintf("running command %s %s", command, strings.Join(args, " ")))
+	cmd = exec.CommandContext(ctx, command, args...)
+	cmd.Env = append(os.Environ(), "DEBUG=")
+	cmd.Cancel = func() error {
+		slog.Debug("signalling child")
+		err := cmd.Process.Signal(os.Interrupt)
+		if err != nil {
+			slog.Error(fmt.Sprintf("signal error: %v", err))
+		}
+		return err
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return
+	}
+
+	go io.Copy(os.Stdout, stdout)
+	go io.Copy(os.Stderr, stderr)
+
+	return
+}
+
+type FileWatcher struct {
+	*watcher.Batcher
+}
+
+func NewFileWatcher(pollTime time.Duration) (*FileWatcher, error) {
+	batcher, err := watcher.New(pollTime/5, pollTime, true)
+	if err != nil {
+		return nil, err
+	}
+	return &FileWatcher{batcher}, nil
+}
+
+func (watcher FileWatcher) WatchAllFiles(from string) error {
+	slog.Debug(fmt.Sprintf("watching files under %s", from))
+	err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		// slog.Debug(fmt.Sprintf("adding file %s to watcher", path))
+		if err = watcher.Add(path); err != nil {
+			return err
+		}
+		return nil
+	})
+	return err
+}
+
+func build(ctx context.Context, config DevConfig) error {
+	buildExe := filepath.Join(config.TempDir, "build")
+	cmd, err := RunCommandPiped(ctx, buildExe,
+		"--dest", path.Join(config.TempDir, "output"),
+		"--dev",
+	)
+	// cmd, err := RunCommandPiped(ctx, "./devfakebuild")
+
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+
+	err = cmd.Run()
+	slog.Debug(fmt.Sprintf("build command exited with code %d", cmd.ProcessState.ExitCode()))
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+	return nil
+}
+
+func server(ctx context.Context, devConfig DevConfig) error {
+	serverExe := path.Join(devConfig.TempDir, "server")
+
+	cmd, err := RunCommandPiped(ctx,
+		serverExe,
+		"--port", devConfig.ServerURL.Port(),
+		"--root", path.Join(devConfig.TempDir, "output"),
+		"--in-dev-server",
+	)
+	if err != nil {
+		return errors.WithMessage(err, "error running server command")
+	}
+	// cmd.Env = append(cmd.Env, "DEBUG=1")
+
+	cmdErr := make(chan error, 1)
+	done := make(chan struct{})
+	err = cmd.Start()
+	if err != nil {
+		return errors.WithMessage(err, fmt.Sprintf("error starting server binary"))
+	}
+
+	go func() {
+		err := cmd.Wait()
+		if err == nil && cmd.ProcessState.Exited() {
+			err = errors.Errorf("server exited unexpectedly")
+		}
+
+		cmdErr <- err
+		close(done)
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			slog.Debug("server context done")
+			cmd.Process.Signal(os.Interrupt)
+			<-done
+		case err := <-cmdErr:
+			return err
+		}
+	}
+}
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	var wg sync.WaitGroup
+
+	devConfig := DevConfig{}
+	help, err := conf.Parse("", &devConfig)
+	if err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing dev configuration: %v", err)
+	}
+
+	slog.Debug(fmt.Sprintf("using folder %s for build output", devConfig.TempDir))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	slog.Debug("setting interrupt handler")
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		sig := <-c
+		slog.Info(fmt.Sprintf("shutting down on signal %d", sig))
+		cancel()
+		sig = <-c
+		slog.Info(fmt.Sprintf("got second signal, dying %d", sig))
+		os.Exit(1)
+	}()
+
+	serverChan := make(chan bool, 1)
+	eventsource := eventsource.New(nil, nil)
+	defer eventsource.Close()
+	srv := http.Server{
+		Addr: devConfig.BaseURL.Host,
+	}
+	devCtx, devCancel := context.WithTimeout(ctx, 1*time.Second)
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer devCancel()
+		slog.Debug("waiting for first server launch")
+		<-serverChan
+		slog.Debug("got first server launch event")
+
+		http.Handle("/", &httputil.ReverseProxy{
+			Rewrite: func(req *httputil.ProxyRequest) {
+				req.SetURL(devConfig.ServerURL.URL)
+				req.Out.Host = req.In.Host
+			},
+		})
+		http.Handle("/_/reload", eventsource)
+		done := make(chan bool)
+		go func() {
+			err := srv.ListenAndServe()
+			if err != nil && err != http.ErrServerClosed {
+				slog.Error(err.Error())
+				cancel()
+			}
+			done <- true
+		}()
+		go func() {
+			for {
+				select {
+				case ready := <-serverChan:
+					if ready {
+						slog.Debug("sending reload message")
+						eventsource.SendEventMessage("reload", "", "")
+					} else {
+						slog.Debug("server not ready")
+					}
+				}
+			}
+		}()
+		slog.Info(fmt.Sprintf("dev server listening on %s", devConfig.BaseURL.Host))
+		<-done
+		slog.Debug("dev server closed")
+	}()
+
+	fw, err := NewFileWatcher(500 * time.Millisecond)
+	if err != nil {
+		log.Fatalf("error creating file watcher: %v", err)
+	}
+	err = fw.WatchAllFiles("content")
+	if err != nil {
+		log.Fatalf("could not watch files in content directory: %v", err)
+	}
+	err = fw.WatchAllFiles("templates")
+	if err != nil {
+		log.Fatalf("could not watch files in templates directory: %v", err)
+	}
+
+	var exitCode int
+	serverErr := make(chan error, 1)
+loop:
+	for {
+		serverCtx, stopServer := context.WithCancel(ctx)
+		slog.Debug("starting build")
+
+		err := build(ctx, devConfig)
+		if err != nil {
+			slog.Error(fmt.Sprintf("build error: %v", err))
+			// don't set up the server until there's a FS change event
+		} else {
+			slog.Debug("setting up server")
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				serverChan <- true
+				serverErr <- server(serverCtx, devConfig)
+			}()
+		}
+
+		select {
+		case <-ctx.Done():
+			slog.Debug("main context cancelled")
+			slog.Debug("calling server shutdown")
+			srv.Shutdown(devCtx)
+			exitCode = 1
+			break loop
+		case event := <-fw.Events:
+			slog.Debug(fmt.Sprintf("event received: %v", event))
+			stopServer()
+			serverChan <- false
+			slog.Debug("waiting for server shutdown")
+			<-serverErr
+			slog.Debug("server shutdown completed")
+			continue
+		case err = <-serverErr:
+			if err != nil && err != context.Canceled {
+				var exerr *exec.ExitError
+				slog.Error(fmt.Sprintf("server reported error: %v", err))
+				if errors.As(err, &exerr) {
+					slog.Debug("server exit error")
+					exitCode = exerr.ExitCode()
+				} else {
+					slog.Debug("server other error")
+					exitCode = 1
+				}
+				break
+			}
+			slog.Debug("no error or server context cancelled")
+			continue
+		}
+
+		slog.Debug("waiting on server")
+		exitCode = 0
+		break
+	}
+
+	slog.Debug("waiting for wg before shutting down")
+	wg.Wait()
+	os.Exit(exitCode)
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..bae215a
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"log/slog"
+	"os"
+	"os/signal"
+	"sync"
+
+	"website/internal/server"
+
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+var (
+	CommitSHA string
+	ShortSHA  string
+)
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("server: ")
+
+	runtimeConfig := server.Config{}
+	help, err := conf.Parse("", &runtimeConfig)
+	if err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing runtime configuration: %v", err)
+	}
+
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt)
+	sv, err := server.New(&runtimeConfig)
+	if err != nil {
+		log.Fatalf("error setting up server: %v", err)
+	}
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sig := <-c
+		log.Printf("signal captured: %v", sig)
+		<-sv.Stop()
+		slog.Debug("server stopped")
+	}()
+
+	sErr := make(chan error)
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sErr <- sv.Start()
+	}()
+	if !runtimeConfig.InDevServer {
+		log.Printf("server listening on %s", sv.Addr)
+	}
+
+	err = <-sErr
+	if err != nil {
+		// Error starting or closing listener:
+		log.Fatalf("error: %v", err)
+	}
+	wg.Wait()
+}
diff --git a/config.toml b/config.toml
index cf15f11..55b2508 100644
--- a/config.toml
+++ b/config.toml
@@ -2,47 +2,76 @@ default_language = "en-GB"
 base_url = "https://alanpearce.eu"
 
 title = "Alan Pearce"
+email = "alan@alanpearce.eu"
 description = "Developer, Emacs User"
 
-generate_feed = true
-
-highlight_code = true
-highlight_theme = "ascetic-white"
-
-theme = "xmin"
+domain_start_date = "2014-06-07"
+original_domain = "alanpearce.eu"
 
 [[taxonomies]]
 name = "tags"
 feed = true
 
-[extra]
-footer = "Licensed under a <a rel=\"license\" href=\"http://creativecommons.org/licenses/by/4.0/\">Creative Commons Attribution 4.0 International License</a>."
-gpg_fingerprint = "48E6 576C 0707 388C B8BE FD0C CD4B EB92 A8D4 6583"
-gpg_url = "/public_key.asc"
-author_name = "Alan Pearce"
-author_image = "/img/me-thumb.jpg"
+[content-security-policy]
+default-src = [
+  "'none'",
+]
+image-src = [
+  "'self'",
+  "http://gc.zgo.at",
+]
+script-src = [
+  "'self'",
+  "http://gc.zgo.at",
+]
+style-src = [
+  "'unsafe-inline'",
+]
+frame-ancestors = [
+  "https://kagi.com",
+]
+connect-src = [
+  "https://alanpearce-eu.goatcounter.com/count",
+]
+require-trusted-types-for = [
+  "'script'",
+]
+
+[extra.headers]
+cache-control = "max-age=14400"
+x-content-type-options = "nosniff"
 
-[[extra.menu.main]]
+[[menus.main]]
     name = "Home"
     url = "/"
-    weight = 1
-[[extra.menu.main]]
+[[menus.main]]
     name = "Posts"
     url = "/post/"
-[[extra.menu.main]]
+[[menus.main]]
+    name = "Feed"
+    url = "/atom.xml"
+[[menus.main]]
     name = "Tags"
     url = "/tags/"
-[[extra.menu.main]]
+[[menus.main]]
     name = "Repositories"
     url = "https://git.alanpearce.eu"
 
-[[extra.menu.contact]]
-    name = "alan@alanpearce.eu"
-    url = "mailto:alan@alanpearce.eu"
-    weight = 1
-[[extra.menu.contact]]
-    name = "GitLab"
-    url = "https://gitlab.com/alanpearce"
-[[extra.menu.contact]]
-    name = "GitHub"
-    url = "https://github.com/alanpearce"
+[[menus.me]]
+  name = "Codeberg"
+  url = "https://codeberg.org/alanpearce"
+[[menus.me]]
+  name = "GitLab"
+  url = "https://gitlab.com/alanpearce/"
+[[menus.me]]
+  name = "GitHub"
+  url = "https://github.com/alanpearce/"
+[[menus.me]]
+  name = "LinkedIn"
+  url = "https://www.linkedin.com/in/alanpearceeu/"
+[[menus.me]]
+  name = "Mastodon"
+  url = "https://ieji.de/@alanpearce"
+[[menus.me]]
+  name = "BlueSky"
+  url = "https://bsky.app/profile/alanpearce.eu"
diff --git a/content/LICENSE b/content/LICENSE
new file mode 100644
index 0000000..4ea99c2
--- /dev/null
+++ b/content/LICENSE
@@ -0,0 +1,395 @@
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+     Considerations for licensors: Our public licenses are
+     intended for use by those authorized to give the public
+     permission to use material in ways otherwise restricted by
+     copyright and certain other rights. Our licenses are
+     irrevocable. Licensors should read and understand the terms
+     and conditions of the license they choose before applying it.
+     Licensors should also secure all rights necessary before
+     applying our licenses so that the public can reuse the
+     material as expected. Licensors should clearly mark any
+     material not subject to the license. This includes other CC-
+     licensed material, or material used under an exception or
+     limitation to copyright. More considerations for licensors:
+    wiki.creativecommons.org/Considerations_for_licensors
+
+     Considerations for the public: By using one of our public
+     licenses, a licensor grants the public permission to use the
+     licensed material under specified terms and conditions. If
+     the licensor's permission is not necessary for any reason--for
+     example, because of any applicable exception or limitation to
+     copyright--then that use is not regulated by the license. Our
+     licenses grant only permissions under copyright and certain
+     other rights that a licensor has authority to grant. Use of
+     the licensed material may still be restricted for other
+     reasons, including because others have copyright or other
+     rights in the material. A licensor may make special requests,
+     such as asking that all changes be marked or described.
+     Although not required by our licenses, you are encouraged to
+     respect those requests where reasonable. More considerations
+     for the public:
+    wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+  a. Adapted Material means material subject to Copyright and Similar
+     Rights that is derived from or based upon the Licensed Material
+     and in which the Licensed Material is translated, altered,
+     arranged, transformed, or otherwise modified in a manner requiring
+     permission under the Copyright and Similar Rights held by the
+     Licensor. For purposes of this Public License, where the Licensed
+     Material is a musical work, performance, or sound recording,
+     Adapted Material is always produced where the Licensed Material is
+     synched in timed relation with a moving image.
+
+  b. Adapter's License means the license You apply to Your Copyright
+     and Similar Rights in Your contributions to Adapted Material in
+     accordance with the terms and conditions of this Public License.
+
+  c. Copyright and Similar Rights means copyright and/or similar rights
+     closely related to copyright including, without limitation,
+     performance, broadcast, sound recording, and Sui Generis Database
+     Rights, without regard to how the rights are labeled or
+     categorized. For purposes of this Public License, the rights
+     specified in Section 2(b)(1)-(2) are not Copyright and Similar
+     Rights.
+
+  d. Effective Technological Measures means those measures that, in the
+     absence of proper authority, may not be circumvented under laws
+     fulfilling obligations under Article 11 of the WIPO Copyright
+     Treaty adopted on December 20, 1996, and/or similar international
+     agreements.
+
+  e. Exceptions and Limitations means fair use, fair dealing, and/or
+     any other exception or limitation to Copyright and Similar Rights
+     that applies to Your use of the Licensed Material.
+
+  f. Licensed Material means the artistic or literary work, database,
+     or other material to which the Licensor applied this Public
+     License.
+
+  g. Licensed Rights means the rights granted to You subject to the
+     terms and conditions of this Public License, which are limited to
+     all Copyright and Similar Rights that apply to Your use of the
+     Licensed Material and that the Licensor has authority to license.
+
+  h. Licensor means the individual(s) or entity(ies) granting rights
+     under this Public License.
+
+  i. Share means to provide material to the public by any means or
+     process that requires permission under the Licensed Rights, such
+     as reproduction, public display, public performance, distribution,
+     dissemination, communication, or importation, and to make material
+     available to the public including in ways that members of the
+     public may access the material from a place and at a time
+     individually chosen by them.
+
+  j. Sui Generis Database Rights means rights other than copyright
+     resulting from Directive 96/9/EC of the European Parliament and of
+     the Council of 11 March 1996 on the legal protection of databases,
+     as amended and/or succeeded, as well as other essentially
+     equivalent rights anywhere in the world.
+
+  k. You means the individual or entity exercising the Licensed Rights
+     under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+  a. License grant.
+
+       1. Subject to the terms and conditions of this Public License,
+          the Licensor hereby grants You a worldwide, royalty-free,
+          non-sublicensable, non-exclusive, irrevocable license to
+          exercise the Licensed Rights in the Licensed Material to:
+
+            a. reproduce and Share the Licensed Material, in whole or
+               in part; and
+
+            b. produce, reproduce, and Share Adapted Material.
+
+       2. Exceptions and Limitations. For the avoidance of doubt, where
+          Exceptions and Limitations apply to Your use, this Public
+          License does not apply, and You do not need to comply with
+          its terms and conditions.
+
+       3. Term. The term of this Public License is specified in Section
+          6(a).
+
+       4. Media and formats; technical modifications allowed. The
+          Licensor authorizes You to exercise the Licensed Rights in
+          all media and formats whether now known or hereafter created,
+          and to make technical modifications necessary to do so. The
+          Licensor waives and/or agrees not to assert any right or
+          authority to forbid You from making technical modifications
+          necessary to exercise the Licensed Rights, including
+          technical modifications necessary to circumvent Effective
+          Technological Measures. For purposes of this Public License,
+          simply making modifications authorized by this Section 2(a)
+          (4) never produces Adapted Material.
+
+       5. Downstream recipients.
+
+            a. Offer from the Licensor -- Licensed Material. Every
+               recipient of the Licensed Material automatically
+               receives an offer from the Licensor to exercise the
+               Licensed Rights under the terms and conditions of this
+               Public License.
+
+            b. No downstream restrictions. You may not offer or impose
+               any additional or different terms or conditions on, or
+               apply any Effective Technological Measures to, the
+               Licensed Material if doing so restricts exercise of the
+               Licensed Rights by any recipient of the Licensed
+               Material.
+
+       6. No endorsement. Nothing in this Public License constitutes or
+          may be construed as permission to assert or imply that You
+          are, or that Your use of the Licensed Material is, connected
+          with, or sponsored, endorsed, or granted official status by,
+          the Licensor or others designated to receive attribution as
+          provided in Section 3(a)(1)(A)(i).
+
+  b. Other rights.
+
+       1. Moral rights, such as the right of integrity, are not
+          licensed under this Public License, nor are publicity,
+          privacy, and/or other similar personality rights; however, to
+          the extent possible, the Licensor waives and/or agrees not to
+          assert any such rights held by the Licensor to the limited
+          extent necessary to allow You to exercise the Licensed
+          Rights, but not otherwise.
+
+       2. Patent and trademark rights are not licensed under this
+          Public License.
+
+       3. To the extent possible, the Licensor waives any right to
+          collect royalties from You for the exercise of the Licensed
+          Rights, whether directly or through a collecting society
+          under any voluntary or waivable statutory or compulsory
+          licensing scheme. In all other cases the Licensor expressly
+          reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+  a. Attribution.
+
+       1. If You Share the Licensed Material (including in modified
+          form), You must:
+
+            a. retain the following if it is supplied by the Licensor
+               with the Licensed Material:
+
+                 i. identification of the creator(s) of the Licensed
+                    Material and any others designated to receive
+                    attribution, in any reasonable manner requested by
+                    the Licensor (including by pseudonym if
+                    designated);
+
+                ii. a copyright notice;
+
+               iii. a notice that refers to this Public License;
+
+                iv. a notice that refers to the disclaimer of
+                    warranties;
+
+                 v. a URI or hyperlink to the Licensed Material to the
+                    extent reasonably practicable;
+
+            b. indicate if You modified the Licensed Material and
+               retain an indication of any previous modifications; and
+
+            c. indicate the Licensed Material is licensed under this
+               Public License, and include the text of, or the URI or
+               hyperlink to, this Public License.
+
+       2. You may satisfy the conditions in Section 3(a)(1) in any
+          reasonable manner based on the medium, means, and context in
+          which You Share the Licensed Material. For example, it may be
+          reasonable to satisfy the conditions by providing a URI or
+          hyperlink to a resource that includes the required
+          information.
+
+       3. If requested by the Licensor, You must remove any of the
+          information required by Section 3(a)(1)(A) to the extent
+          reasonably practicable.
+
+       4. If You Share Adapted Material You produce, the Adapter's
+          License You apply must not prevent recipients of the Adapted
+          Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+  a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+     to extract, reuse, reproduce, and Share all or a substantial
+     portion of the contents of the database;
+
+  b. if You include all or a substantial portion of the database
+     contents in a database in which You have Sui Generis Database
+     Rights, then the database in which You have Sui Generis Database
+     Rights (but not its individual contents) is Adapted Material; and
+
+  c. You must comply with the conditions in Section 3(a) if You Share
+     all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+  c. The disclaimer of warranties and limitation of liability provided
+     above shall be interpreted in a manner that, to the extent
+     possible, most closely approximates an absolute disclaimer and
+     waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+  a. This Public License applies for the term of the Copyright and
+     Similar Rights licensed here. However, if You fail to comply with
+     this Public License, then Your rights under this Public License
+     terminate automatically.
+
+  b. Where Your right to use the Licensed Material has terminated under
+     Section 6(a), it reinstates:
+
+       1. automatically as of the date the violation is cured, provided
+          it is cured within 30 days of Your discovery of the
+          violation; or
+
+       2. upon express reinstatement by the Licensor.
+
+     For the avoidance of doubt, this Section 6(b) does not affect any
+     right the Licensor may have to seek remedies for Your violations
+     of this Public License.
+
+  c. For the avoidance of doubt, the Licensor may also offer the
+     Licensed Material under separate terms or conditions or stop
+     distributing the Licensed Material at any time; however, doing so
+     will not terminate this Public License.
+
+  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+     License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+  a. The Licensor shall not be bound by any additional or different
+     terms or conditions communicated by You unless expressly agreed.
+
+  b. Any arrangements, understandings, or agreements regarding the
+     Licensed Material not stated herein are separate from and
+     independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+  a. For the avoidance of doubt, this Public License does not, and
+     shall not be interpreted to, reduce, limit, restrict, or impose
+     conditions on any use of the Licensed Material that could lawfully
+     be made without permission under this Public License.
+
+  b. To the extent possible, if any provision of this Public License is
+     deemed unenforceable, it shall be automatically reformed to the
+     minimum extent necessary to make it enforceable. If the provision
+     cannot be reformed, it shall be severed from this Public License
+     without affecting the enforceability of the remaining terms and
+     conditions.
+
+  c. No term or condition of this Public License will be waived and no
+     failure to comply consented to unless expressly agreed to by the
+     Licensor.
+
+  d. Nothing in this Public License constitutes or may be interpreted
+     as a limitation upon, or waiver of, any privileges and immunities
+     that apply to the Licensor or You, including from the legal
+     processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/content/_index.md b/content/_index.md
index add1ca4..7239667 100644
--- a/content/_index.md
+++ b/content/_index.md
@@ -1,9 +1,7 @@
 +++
 title = "Home"
-sort_by = "date"
-paginate_reversed = true
 +++
-<p class="p-note">
-I work as a Full-stack Developer in Berlin.  I occasionally write about Emacs and
+<p class="p-note note">
+I work as a back-end and infrastructure developer in Berlin.  I occasionally write about Emacs and
 development-related topics.
 </p>
diff --git a/content/post/_index.md b/content/post/_index.md
deleted file mode 100644
index e0d2523..0000000
--- a/content/post/_index.md
+++ /dev/null
@@ -1,6 +0,0 @@
-+++
-title = "Posts"
-sort_by = "date"
-paginate_reversed = true
-transparent = true
-+++
diff --git a/content/post/homesteading.md b/content/post/homesteading.md
new file mode 100644
index 0000000..52ce713
--- /dev/null
+++ b/content/post/homesteading.md
@@ -0,0 +1,13 @@
++++
+title = "Homesteading"
+description = "Running my own code"
+date = 2023-09-22T10:09:22.141Z
+[taxonomies]
+tags = ["website"]
++++
+
+I switched away from [Zola](https://www.getzola.org/) and made my own [static site builder](https://git.alanpearce.eu/website/tree) that uses only HTML templates. I've been wanting to do this since at least 2017, when I started to work on a [homestead project](https://git.alanpearce.eu/homestead/tree/src?h=2017), which I didn't quite finish.
+
+The recent release of [Bun](https://bun.sh/), which touts itself as an "all-in-one JavaScript toolkit" encouraged me to play around with it.  I have to say, I am surprised by how energising it was; an antidote to the "JavaScript fatigue" I've read about and definitely experienced. 
+
+I decided that I'd start by serving my site using Bun's web server, then I added site generation later. I have been intrigued by the idea of DOM templating ever since I read about it on [Camen Design](https://camendesign.com/dom_templating) [in 2012](https://camendesign.com/code/dom_templating/domtemplate_v4.rem) and I've enjoyed putting it into practice.
diff --git a/content/post/nixos-on-nanopi-r5s.md b/content/post/nixos-on-nanopi-r5s.md
new file mode 100644
index 0000000..185bd30
--- /dev/null
+++ b/content/post/nixos-on-nanopi-r5s.md
@@ -0,0 +1,142 @@
++++
+title = "Running NixOS on a NanoPi R5S"
+date = 2023-07-30T08:51:46Z
+[taxonomies]
+tags = ["nixos", "home-networking", "infrastructure"]
++++
+
+I managed to get [NixOS](https://nixos.org) running on my [NanoPi R5S](https://www.friendlyelec.com/index.php?route=product/product&product_id=287) ([FriendlyElec Wiki](https://wiki.friendlyelec.com/wiki/index.php/NanoPi_R5S)).
+
+Firstly, I flashed a pre-built stock Debian image from [inindev](https://github.com/inindev/nanopi-r5) to an SD card. This can be used as a rescue system later on. 
+
+From that SD card, I then flashed the same system onto the internal <abbr title="embedded MultiMediaCard">eMMC</abbr> Storage. I only really needed to this to ensure UBoot was correctly installed; I think there will be an easier way to do it.
+
+I had nix already installed on the <abbr title="Non-Volatile Memory Express">NVMe</abbr> <abbr title="Solid-State Drive">SSD</abbr> along with a home directory. I bind-mounted `/nix` and `/home` following the fstab I had previously set up:
+
+```conf
+UUID=replaceme  /mnt    ext4    relatime,lazytime   0 2
+/mnt/nix        /nix    none    defaults,bind       0 0
+/mnt/srv        /srv    none    defaults,bind       0 0
+/mnt/home       /home   none    defaults,bind       0 0
+```
+
+I then created a user for myself using that home directory, I had full access to nix in the new Debian environment. This meant I had access to `nixos-install`. 
+
+I wanted to use the [extlinux support in UBoot](https://u-boot.readthedocs.io/en/latest/develop/distro.html#boot-configuration-files), so I made `/mnt/boot` point to `/boot` on the <abbr>eMMC</abbr>:
+
+```sh
+mkdir /mnt/{emmc,boot}
+mount LABEL=rootfs /mnt/emmc
+mount --bind /mnt/emmc /mnt/boot
+```
+
+<aside>
+One could <em>probably</em> delete everything else on the <abbr>eMMC</abbr> and move the contents of <code>/mnt/emmc/boot</code> to <code>/mnt/emmc</code>, thus obviating the need to bind-mount <code>/boot</code>
+</aside>
+
+I ran `nixos-generate-config` as usual, which set up the mount points in `hardware-configuration.nix` correctly. `configuration.nix` needed a bit of tweaking. My first booting configuration was something like this, mostly borrowed from [Artem Boldariev's comment](https://github.com/inindev/nanopi-r5/issues/11#issue-1789308883):
+
+```nix
+{ config
+, pkgs
+, lib
+, ...
+}:
+let
+  fsTypes = [ "f2fs" "ext" "exfat" "vfat" ];
+in
+{
+  imports = [ ./hardware-configuration.nix ];
+  boot = {
+    kernelPackages = pkgs.linuxKernel.packages.linux_6_4;
+
+    # partial Rockchip related changes from Debian 12 kernel version 6.1
+    # Also, see here:
+    # https://discourse.nixos.org/t/how-to-provide-missing-headers-to-a-kernel-build/11422/3
+    kernelPatches = [
+      {
+        name = "rockchip-config.patch";
+        patch = null;
+        extraConfig = ''
+          PHY_ROCKCHIP_PCIE Y
+          PCIE_ROCKCHIP_EP y
+          PCIE_ROCKCHIP_DW_HOST y
+          ROCKCHIP_VOP2 y
+        '';
+      }
+      {
+        name = "status-leds.patch";
+        patch = null;
+        # old:
+        # LEDS_TRIGGER_NETDEV y
+        extraConfig = ''
+          LED_TRIGGER_PHY y
+          USB_LED_TRIG y
+          LEDS_BRIGHTNESS_HW_CHANGED y
+          LEDS_TRIGGER_MTD y
+        '';
+      }
+    ];
+    
+    supportedFilesystems = fsTypes;
+    initrd.supportedFilesystems = fsTypes;
+
+    initrd.availableKernelModules = [
+      ## Rockchip
+      ## Storage
+      "sdhci_of_dwcmshc"
+      "dw_mmc_rockchip"
+
+      "analogix_dp"
+      "io-domain"
+      "rockchip_saradc"
+      "rockchip_thermal"
+      "rockchipdrm"
+      "rockchip-rga"
+      "pcie_rockchip_host"
+      "phy-rockchip-pcie"
+      "phy_rockchip_snps_pcie3"
+      "phy_rockchip_naneng_combphy"
+      "phy_rockchip_inno_usb2"
+      "dwmac_rk"
+      "dw_wdt"
+      "dw_hdmi"
+      "dw_hdmi_cec"
+      "dw_hdmi_i2s_audio"
+      "dw_mipi_dsi"
+    ];
+    loader = {
+      timeout = 3;
+      grub.enable = false;
+      generic-extlinux-compatible = {
+        enable = true;
+        useGenerationDeviceTree = true;
+      };
+    };
+  };
+  # this file is from debian and should be in /boot/
+  hardware.deviceTree.name = "../../rk3568-nanopi-r5s.dtb";
+  # Most Rockchip CPUs (especially with hybrid cores) work best with "schedutil"
+  powerManagement.cpuFreqGovernor = "schedutil";
+  
+  boot.kernelParams = [
+    "console=tty1"
+    "console=ttyS2,1500000"
+    "earlycon=uart8250,mmio32,0xfe660000"
+  ];
+  # Let's blacklist the Rockchips RTC module so that the
+  # battery-powered HYM8563 (rtc_hym8563 kernel module) will be used
+  # by default
+  boot.blacklistedKernelModules = [ "rtc_rk808" ];
+
+  # ... typical config omitted for brevity
+}
+```
+
+Due to the custom kernel configuration, building takes a while. I set up a [distributed build](https://nixos.org/manual/nix/stable/advanced-topics/distributed-builds.html) to speed things up, using a [Hetzner Cloud](https://www.hetzner.com/cloud) CAX21 ARM64 instance (although I could have used an x86_64 system with one of the methods mentioned on the [NixOS on ARM NixOS wiki page](https://nixos.wiki/wiki/NixOS_on_ARM#Build_your_own_image_natively)). This made for a very long `nixos-install` command line:
+
+```sh
+sudo env PATH=$PATH =nixos-install --root /mnt --no-channel-copy --channel https://nixos.org/channels/nixos-23.05 --option builders'ssh://my-host aarch64-linux /root/.ssh/id_pappel_nixpkgs 4 2 big-parallel' --option builders-use-substitutes true --max-jobs 0
+```
+
+I added `setenv bootmeths "extlinux"` to `/boot/boot.txt` and ran `/boot/mkscr.sh` as root to ensure that UBoot would search for the `extlinux.conf` file
diff --git a/content/post/now-on-three-continents.md b/content/post/now-on-three-continents.md
new file mode 100644
index 0000000..1a2828a
--- /dev/null
+++ b/content/post/now-on-three-continents.md
@@ -0,0 +1,25 @@
++++
+title = "Now on three continents"
+description = "This website is now hosted on three continents"
+date = 2023-07-02T07:55:35Z
+[taxonomies]
+tags = ["website", "infrastructure"]
++++
+
+This website is now hosted on three continents.
+
+I recently changed the hosting for this site to [fly](http://fly.io), since I was rather intrigued by the idea of being able to run three small <abbr>VMs</abbr> (<dfn id="VMs">Virtual Machines</dfn>) worldwide for free. I would gladly have paid a small amount for their services. If they didn't have a free allowance for <abbr>VMs</abbr> then it would only be around $6 a month, so I'm not worried about them removing the free allowance.
+
+Previously it was running on one [Hetzner](https://www.hetzner.com) <abbr title="Virtual Machine">VM</abbr> in Nuremberg, Germany that I set up and maintained myself. The maintenance wasn't a problem for me, but rather the idea of slow loading times for anyone reading this outside of Europe.
+
+American visitors should notice a definite speedup now, as there's a server on the west coast and for the few visitors in the Asia-Pacific region, there's also a server in Australia. I kept track of the response time before and after the change using the [Online or not](https://onlineornot.com/) [Do I need a CDN?](https://onlineornot.com/do-i-need-a-cdn) tool, which you can see in the table below (measured in <abbr title="milliseconds">ms</abbr>)
+
+| Region                  | Before | After |
+|-------------------------|--------|-------|
+| Europe (Frankfurt)      | 62     | 32    |
+| US East (N. Virginia)   | 348    | 185   |
+| US West (N. California) | 503    | 61    |
+| Asia Pacific (Tokyo)    | 732    | 251   |
+| Asia Pacific (Sydney)   | 1114   | 76    |
+
+I do find it rather amusing that I spend more time tinkering with the site than actually posting anything, but, for once, tinkering has actually led to me posting something (this post). I would like to think that this might encourage me to post more in the future, but only time will tell.
diff --git a/content/post/repository-management-with-ghq.md b/content/post/repository-management-with-ghq.md
index c225ace..dd21db9 100644
--- a/content/post/repository-management-with-ghq.md
+++ b/content/post/repository-management-with-ghq.md
@@ -67,10 +67,10 @@ sequence:
 bindkey '\es' cd-project-widget
 ```
 
-Now I can press `M-s` in a shell, start typing "dotfiles" and press enter to `cd`
-to my [dotfiles][] project. Pretty neat!
+Now I can press `M-s` in a shell, start typing "nixfiles" and press enter to `cd`
+to my [nixfiles][] project. Pretty neat!
 
 [ghq]:https://github.com/motemen/ghq
 [fzf]:https://github.com/junegunn/fzf
 [fzf-cd-widget]:https://github.com/junegunn/fzf/blob/337cdbb37c1efc49b09b4cacc6e9ee1369c7d76d/shell/key-bindings.zsh#L40-L54
-[dotfiles]:https://git.alanpearce.eu/dotfiles
+[nixfiles]:https://git.alanpearce.eu/dotfiles
diff --git a/content/post/self-hosted-git.md b/content/post/self-hosted-git.md
index 3bdbffb..ab88e78 100644
--- a/content/post/self-hosted-git.md
+++ b/content/post/self-hosted-git.md
@@ -144,4 +144,4 @@ I want, without consuming many system resources with daemons.
 [dotfiles-github]:https://github.com/alanpearce/dotfiles
 [wildrepos]:http://gitolite.com/gitolite/wild/
 [ghq]:https://github.com/motemen/ghq
-[using-ghq]:{{< relref "/post/repository-management-with-ghq.md" >}} "Repository management with ghq"
+[using-ghq]:/post/repository-management-with-ghq/ "Repository management with ghq"
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..2cccff2
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,10 @@
+(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
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..a9eab85
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,102 @@
+{
+  "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"
+      }
+    },
+    "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": 1714213793,
+        "narHash": "sha256-Yg5D5LhyAZvd3DZrQQfJAVK8K3TkUYKooFtH1ulM0mw=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "d6f6eb2a984f2ba9a366c31e4d36d65465683450",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "gomod2nix": "gomod2nix",
+        "nixpkgs": "nixpkgs",
+        "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
new file mode 100644
index 0000000..85f7915
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,59 @@
+{
+  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";
+  };
+
+  outputs = { self, nixpkgs, utils, gomod2nix, ... }:
+    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 ];
+              packages = with pkgs; [
+                gopls
+                gotools
+                go-tools
+                gomod2nix.packages.${system}.default
+                gci
+                netlify-cli
+                sentry-cli
+              ] ++ commonShellPackages;
+            };
+          };
+          checks = rec {
+            default = hyperlink;
+            hyperlink = pkgs.runCommandLocal "hyperlink" { } ''
+              ${pkgs.hyperlink}/bin/hyperlink ${packages.website}/website/public
+              touch $out
+            '';
+          };
+        });
+}
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..5b25a9d
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,41 @@
+# fly.toml app configuration file generated for homestead on 2023-09-14T11:40:37+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = "alanpearce-eu"
+primary_region = "ams"
+
+[build]
+  image = "registry.fly.io/alanpearce-eu"
+
+[env]
+  PORT = "80"
+  REDIRECT_OTHER_HOSTNAMES = "true"
+  BASE_URL = "https://alanpearce.eu"
+
+[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]
+    type = "requests"
+    hard_limit = 20000
+    soft_limit = 15000
+[http_service.http_options.response]
+  pristine = true
+[[http_service.checks]]
+  grace_period = "15s"
+  interval = "30s"
+  method = "GET"
+  timeout = "1s"
+  path = "/health"
+  [http_service.checks.headers]
+    Host = "fly-internal"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4e8682e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,49 @@
+module website
+
+go 1.22.1
+
+require (
+	github.com/BurntSushi/toml v1.3.2
+	github.com/PuerkitoBio/goquery v1.9.1
+	github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e
+	github.com/adrg/frontmatter v0.2.0
+	github.com/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd
+	github.com/antchfx/xmlquery v1.4.0
+	github.com/antchfx/xpath v1.3.0
+	github.com/ardanlabs/conf/v3 v3.1.7
+	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/getsentry/sentry-go v0.27.0
+	github.com/gohugoio/hugo v0.125.4
+	github.com/otiai10/copy v1.14.0
+	github.com/pkg/errors v0.9.1
+	github.com/shengyanli1982/law v0.1.13
+	github.com/yuin/goldmark v1.7.1
+	golang.org/x/net v0.24.0
+)
+
+replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562
+
+require (
+	github.com/andybalholm/cascadia v1.3.2 // indirect
+	github.com/bep/godartsass v1.2.0 // indirect
+	github.com/bep/godartsass/v2 v2.0.0 // indirect
+	github.com/bep/golibsass v1.1.1 // indirect
+	github.com/cli/safeexec v1.0.1 // indirect
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/gobwas/glob v0.2.3 // indirect
+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mitchellh/hashstructure v1.1.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.1 // indirect
+	github.com/spf13/afero v1.11.0 // indirect
+	github.com/spf13/cast v1.6.0 // indirect
+	github.com/tdewolff/parse/v2 v2.7.13 // indirect
+	golang.org/x/sync v0.7.0 // indirect
+	golang.org/x/sys v0.19.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..1368a63
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,321 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
+github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
+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/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
+github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
+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/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
+github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
+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/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd h1:FD3sn3oFA0wySr3iH+47H3lIts9qT9nZfoF935hxaH0=
+github.com/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd/go.mod h1:jXAZMa2S7OGjBCXWeQVOIZd+LXToszS2zCh0NiHRGvE=
+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/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0=
+github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
+github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
+github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo=
+github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/bep/gitmap v1.1.2 h1:zk04w1qc1COTZPPYWDQHvns3y1afOsdRfraFQ3qI840=
+github.com/bep/gitmap v1.1.2/go.mod h1:g9VRETxFUXNWzMiuxOwcudo6DfZkW9jOsOW0Ft4kYaY=
+github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
+github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
+github.com/bep/godartsass v1.2.0 h1:E2VvQrxAHAFwbjyOIExAMmogTItSKodoKuijNrGm5yU=
+github.com/bep/godartsass v1.2.0/go.mod h1:6LvK9RftsXMxGfsA0LDV12AGc4Jylnu6NgHL+Q5/pE8=
+github.com/bep/godartsass/v2 v2.0.0 h1:Ruht+BpBWkpmW+yAM2dkp7RSSeN0VLaTobyW0CiSP3Y=
+github.com/bep/godartsass/v2 v2.0.0/go.mod h1:AcP8QgC+OwOXEq6im0WgDRYK7scDsmZCEW62o1prQLo=
+github.com/bep/golibsass v1.1.1 h1:xkaet75ygImMYjM+FnHIT3xJn7H0xBA9UxSOJjk8Khw=
+github.com/bep/golibsass v1.1.1/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
+github.com/bep/gowebp v0.3.0 h1:MhmMrcf88pUY7/PsEhMgEP0T6fDUnRTMpN8OclDrbrY=
+github.com/bep/gowebp v0.3.0/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
+github.com/bep/lazycache v0.4.0 h1:X8yVyWNVupPd4e1jV7efi3zb7ZV/qcjKQgIQ5aPbkYI=
+github.com/bep/lazycache v0.4.0/go.mod h1:NmRm7Dexh3pmR1EignYR8PjO2cWybFQ68+QgY3VMCSc=
+github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
+github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
+github.com/bep/overlayfs v0.9.2 h1:qJEmFInsW12L7WW7dOTUhnMfyk/fN9OCDEO5Gr8HSDs=
+github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40=
+github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
+github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
+github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
+github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
+github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
+github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+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/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/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
+github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/evanw/esbuild v0.20.2 h1:E4Y0iJsothpUCq7y0D+ERfqpJmPWrZpNybJA3x3I4p8=
+github.com/evanw/esbuild v0.20.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+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/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
+github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+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/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
+github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
+github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
+github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q=
+github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
+github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw=
+github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI=
+github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
+github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
+github.com/gohugoio/hugo v0.125.4 h1:H4sg186C5bUbN1RCDGTNkly/TP/nUkOyxGoMTkX3z20=
+github.com/gohugoio/hugo v0.125.4/go.mod h1:b2O1TXqyxQnMzr6wUpqTWJUuK83/U9i2kfYCovO9Gb0=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0 h1:PCtO5l++psZf48yen2LxQ3JiOXxaRC6v0594NeHvGZg=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0/go.mod h1:g9CCh+Ci2IMbPUrVJuXbBTrA+rIIx5+hDQ4EXYaQDoM=
+github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
+github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
+github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
+github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+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/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+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/hairyhenderson/go-codeowners v0.4.0 h1:Wx/tRXb07sCyHeC8mXfio710Iu35uAy5KYiBdLHdv4Q=
+github.com/hairyhenderson/go-codeowners v0.4.0/go.mod h1:iJgZeCt+W/GzXo5uchFCqvVHZY2T4TAIpvuVlKVkLxc=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
+github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
+github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
+github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+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/kyokomi/emoji/v2 v2.2.12 h1:sSVA5nH9ebR3Zji1o31wu3yOwD1zKXQA2z0zUyeit60=
+github.com/kyokomi/emoji/v2 v2.2.12/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
+github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
+github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0=
+github.com/mitchellh/hashstructure v1.1.0/go.mod h1:xUDAozZz0Wmdiufv0uyhnHkUTN6/6d8ulp4AwfLKrmA=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
+github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
+github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
+github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+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/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
+github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
+github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
+github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+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/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
+github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
+github.com/shengyanli1982/law v0.1.13 h1:BuUYw/71w1dpGnbXLaCyFUHT36wueUQ7AoVephDut4E=
+github.com/shengyanli1982/law v0.1.13/go.mod h1:20k9YnOTwilUB4X5Z4S7TIX5Ek1Ok4xfx8V8ZxIWlyM=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+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/tdewolff/minify/v2 v2.20.20 h1:vhULb+VsW2twkplgsawAoUY957efb+EdiZ7zu5fUhhk=
+github.com/tdewolff/minify/v2 v2.20.20/go.mod h1:GYaLXFpIIwsX99apQHXfGdISUdlA98wmaoWxjT9C37k=
+github.com/tdewolff/parse/v2 v2.7.13 h1:iSiwOUkCYLNfapHoqdLcqZVgvQ0jrsao8YYKP/UJYTI=
+github.com/tdewolff/parse/v2 v2.7.13/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
+github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA=
+github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
+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-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
+github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 h1:QfTh0HpN6hlw6D3vu8DAwC8pBIwikq0AI1evdm+FksE=
+golang.org/x/exp v0.0.0-20221031165847-c99f073a8326/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
+golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220722155257-8c9f86f7a55f/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.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
+golang.org/x/sys v0.19.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+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.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
+golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+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-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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/internal/atom/atom.go b/internal/atom/atom.go
new file mode 100644
index 0000000..f2ca4a9
--- /dev/null
+++ b/internal/atom/atom.go
@@ -0,0 +1,43 @@
+package atom
+
+import (
+	"encoding/xml"
+	"time"
+
+	. "website/internal/config"
+)
+
+func MakeTagURI(config Config, specific string) string {
+	return "tag:" + config.OriginalDomain + "," + config.DomainStartDate + ":" + specific
+}
+
+type Link struct {
+	XMLName xml.Name `xml:"link"`
+	Rel     string   `xml:"rel,attr"`
+	Type    string   `xml:"type,attr"`
+	Href    string   `xml:"href,attr"`
+}
+
+func MakeLink(url string) Link {
+	return Link{
+		Rel:  "alternate",
+		Type: "text/html",
+		Href: url,
+	}
+}
+
+type FeedContent struct {
+	Content string `xml:",innerxml"`
+	Type    string `xml:"type,attr"`
+}
+
+type FeedEntry struct {
+	XMLName xml.Name    `xml:"entry"`
+	Title   string      `xml:"title"`
+	Link    Link        `xml:"link"`
+	Id      string      `xml:"id"`
+	Updated time.Time   `xml:"updated"`
+	Summary string      `xml:"summary,omitempty"`
+	Content FeedContent `xml:"content"`
+	Author  string      `xml:"author>name"`
+}
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
new file mode 100644
index 0000000..90e957c
--- /dev/null
+++ b/internal/builder/builder.go
@@ -0,0 +1,196 @@
+package builder
+
+import (
+	"fmt"
+	"io"
+	"log/slog"
+	"net/url"
+	"os"
+	"path"
+	"slices"
+
+	"website/internal/config"
+
+	cp "github.com/otiai10/copy"
+	"github.com/pkg/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"`
+}
+
+func mkdirp(dirs ...string) error {
+	return os.MkdirAll(path.Join(dirs...), 0755)
+}
+
+func outputToFile(output io.Reader, filename ...string) error {
+	slog.Debug(fmt.Sprintf("outputting file %s", path.Join(filename...)))
+	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE, 0644)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if _, err := file.ReadFrom(output); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+	return nil
+}
+
+func build(outDir string, config config.Config) error {
+	slog.Debug(fmt.Sprintf("output directory %s", outDir))
+	privateDir := path.Join(outDir, "private")
+	if err := mkdirp(privateDir); err != nil {
+		return errors.WithMessage(err, "could not create private directory")
+	}
+	publicDir := path.Join(outDir, "public")
+	if err := mkdirp(publicDir); err != nil {
+		return errors.WithMessage(err, "could not create public directory")
+	}
+
+	err := cp.Copy("static", publicDir, cp.Options{
+		PreserveTimes:     true,
+		PermissionControl: cp.AddPermission(0755),
+	})
+	if err != nil {
+		return 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")
+	}
+	slog.Debug("reading posts")
+	posts, tags, err := readPosts("content", "post", publicDir)
+	if err != nil {
+		return err
+	}
+
+	for _, post := range posts {
+		if err := mkdirp(publicDir, "post", post.Basename); err != nil {
+			return errors.WithMessage(err, "could not create directory for post")
+		}
+		slog.Debug("rendering post", "post", post.Basename)
+		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
+		}
+	}
+
+	if err := mkdirp(publicDir, "tags"); err != nil {
+		return errors.WithMessage(err, "could not create directory for tags")
+	}
+	slog.Debug("rendering tags list")
+	output, err := renderTags(tags, config, "/tags")
+	if err != nil {
+		return errors.WithMessage(err, "could not render tags")
+	}
+	if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil {
+		return err
+	}
+
+	for _, tag := range tags.ToSlice() {
+		matchingPosts := []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")
+		}
+		slog.Debug("rendering tags page", "tag", tag)
+		output, err := renderListPage(tag, config, matchingPosts, "/tags/"+tag)
+		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
+		}
+
+		slog.Debug("rendering tags feed", "tag", tag)
+		output, 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")
+		}
+		if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil {
+			return err
+		}
+	}
+
+	slog.Debug("rendering list page")
+	listPage, err := renderListPage("", config, posts, "/post")
+	if err != nil {
+		return errors.WithMessage(err, "could not render list page")
+	}
+	if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil {
+		return err
+	}
+
+	slog.Debug("rendering feed")
+	feed, err := renderFeed(config.Title, config, posts, "feed")
+	if err != nil {
+		return errors.WithMessage(err, "could not render feed")
+	}
+	if err := outputToFile(feed, publicDir, "atom.xml"); err != nil {
+		return err
+	}
+
+	slog.Debug("rendering feed styles")
+	feedStyles, err := renderFeedStyles()
+	if err != nil {
+		return errors.WithMessage(err, "could not render feed styles")
+	}
+	if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil {
+		return err
+	}
+
+	slog.Debug("rendering homepage")
+	homePage, err := renderHomepage(config, posts, "/")
+	if err != nil {
+		return errors.WithMessage(err, "could not render homepage")
+	}
+	if err := outputToFile(homePage, publicDir, "index.html"); err != nil {
+		return err
+	}
+
+	slog.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, privateDir, "404.html"); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func BuildSite(ioConfig IOConfig) error {
+	config, err := config.GetConfig()
+	if err != nil {
+		return errors.WithMessage(err, "could not get config")
+	}
+	config.InjectLiveReload = 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")
+		}
+	}
+
+	return build(ioConfig.Destination, *config)
+}
diff --git a/internal/builder/posts.go b/internal/builder/posts.go
new file mode 100644
index 0000000..223531b
--- /dev/null
+++ b/internal/builder/posts.go
@@ -0,0 +1,121 @@
+package builder
+
+import (
+	"bytes"
+	"log/slog"
+	"os"
+	"path"
+	"path/filepath"
+	"slices"
+	"strings"
+	"time"
+
+	"github.com/adrg/frontmatter"
+	mapset "github.com/deckarep/golang-set/v2"
+	"github.com/pkg/errors"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	htmlrenderer "github.com/yuin/goldmark/renderer/html"
+)
+
+type PostMatter struct {
+	Date        time.Time `toml:"date"`
+	Description string    `toml:"description"`
+	Title       string    `toml:"title"`
+	Taxonomies  struct {
+		Tags []string `toml:"tags"`
+	} `toml:"taxonomies"`
+}
+
+type Post struct {
+	Input    string
+	Output   string
+	Basename string
+	URL      string
+	Content  string
+	PostMatter
+}
+
+type Tags mapset.Set[string]
+
+var markdown = goldmark.New(
+	goldmark.WithRendererOptions(
+		htmlrenderer.WithUnsafe(),
+	),
+	goldmark.WithExtensions(
+		extension.GFM,
+		extension.Footnote,
+		extension.Typographer,
+	),
+)
+
+func getPost(filename string) (*PostMatter, []byte, error) {
+	matter := PostMatter{}
+	content, err := os.Open(filename)
+	defer content.Close()
+	if err != nil {
+		return nil, nil, errors.WithMessagef(err, "could not open post %s", filename)
+	}
+	rest, err := frontmatter.MustParse(content, &matter)
+	if err != nil {
+		return nil, nil, errors.WithMessagef(err, "could not parse front matter of post %s", filename)
+	}
+
+	return &matter, rest, nil
+}
+
+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")
+	}
+	return buf.String(), nil
+}
+
+func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, error) {
+	tags := mapset.NewSet[string]()
+	posts := []Post{}
+	subdir := filepath.Join(root, 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", "/")
+	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)
+			slog.Debug("reading post", "post", pathFromRoot)
+			matter, content, err := getPost(pathFromRoot)
+			if err != nil {
+				return nil, nil, err
+			}
+
+			for _, tag := range matter.Taxonomies.Tags {
+				tags.Add(strings.ToLower(tag))
+			}
+
+			slog.Debug("rendering markdown in post", "post", pathFromRoot)
+			html, err := renderMarkdown(content)
+			if err != nil {
+				return nil, nil, err
+			}
+			post := Post{
+				Input:      pathFromRoot,
+				Output:     output,
+				Basename:   filepath.Base(url),
+				URL:        url,
+				PostMatter: *matter,
+				Content:    html,
+			}
+
+			posts = append(posts, post)
+		}
+	}
+	slices.SortFunc(posts, func(a, b Post) int {
+		return b.Date.Compare(a.Date)
+	})
+	return posts, tags, nil
+}
diff --git a/internal/builder/template.go b/internal/builder/template.go
new file mode 100644
index 0000000..74d0418
--- /dev/null
+++ b/internal/builder/template.go
@@ -0,0 +1,371 @@
+package builder
+
+import (
+	"encoding/xml"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/url"
+	"os"
+	"strings"
+	"sync"
+	"time"
+	"website/internal/atom"
+	"website/internal/config"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/a-h/htmlformat"
+	"github.com/antchfx/xmlquery"
+	"github.com/antchfx/xpath"
+	mapset "github.com/deckarep/golang-set/v2"
+	"golang.org/x/net/html"
+)
+
+var (
+	assetsOnce     sync.Once
+	css            string
+	countHTML      *goquery.Document
+	liveReloadHTML *goquery.Document
+	templates      map[string]*os.File = make(map[string]*os.File)
+)
+
+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, err
+		}
+		templates[path] = file
+	}
+	file = templates[path]
+	return
+}
+
+var (
+	imgOnce     sync.Once
+	img         *goquery.Selection
+	urlTemplate *url.URL
+)
+
+type QuerySelection struct {
+	*goquery.Selection
+}
+
+type QueryDocument struct {
+	*goquery.Document
+}
+
+func NewDocumentFromReader(r io.Reader) (*QueryDocument, error) {
+	doc, err := goquery.NewDocumentFromReader(r)
+	return &QueryDocument{doc}, err
+}
+
+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), err
+}
+
+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 html.Seek(0, io.SeekStart)
+	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 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 nil, err
+	}
+
+	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)
+	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 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
+}
+
+func renderFeed(title string, config config.Config, posts []Post, specific string) (io.Reader, error) {
+	reader, err := loadTemplate("templates/feed.xml")
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Seek(0, io.SeekStart)
+	doc, err := xmlquery.Parse(reader)
+	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()
+	feed.SelectElement("updated").FirstChild.Data = string(datetime)
+	tpl := feed.SelectElement("entry")
+	xmlquery.RemoveFromTree(tpl)
+
+	for _, post := range posts {
+		fullURL, err := url.JoinPath(config.BaseURL.String(), post.URL)
+		if err != nil {
+			return nil, err
+		}
+		text, err := xml.MarshalIndent(&atom.FeedEntry{
+			Title:   post.Title,
+			Link:    atom.MakeLink(fullURL),
+			Id:      atom.MakeTagURI(config, post.Basename),
+			Updated: post.Date.UTC(),
+			Summary: post.Description,
+			Author:  config.Title,
+			Content: atom.FeedContent{
+				Content: post.Content,
+				Type:    "html",
+			},
+		}, "  ", "    ")
+		if err != nil {
+			return nil, err
+		}
+		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, err
+		}
+		xmlquery.AddChild(feed, entry.SelectElement("entry"))
+	}
+
+	return strings.NewReader(doc.OutputXML(true)), nil
+}
+
+func renderFeedStyles() (io.Reader, error) {
+	reader, err := loadTemplate("templates/feed-styles.xsl")
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Seek(0, io.SeekStart)
+	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",
+	}
+	doc, err := xmlquery.Parse(reader)
+	expr, err := xpath.CompileWithNS("//xhtml:style", nsMap)
+	if err != nil {
+		return nil, err
+	}
+	style := xmlquery.QuerySelector(doc, expr)
+	xmlquery.AddChild(style, &xmlquery.Node{
+		Type: xmlquery.TextNode,
+		Data: string(css),
+	})
+	return strings.NewReader(doc.OutputXML(true)), nil
+}
+
+func renderHTML(doc *goquery.Document) io.Reader {
+	r, w := io.Pipe()
+
+	// TODO: return errors to main thread
+	go func() {
+		w.Write([]byte("<!doctype html>\n"))
+		err := htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)})
+		if err != nil {
+			slog.Error("error rendering html", "error", err)
+			w.CloseWithError(err)
+			return
+		}
+		defer w.Close()
+	}()
+	return r
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..578390e
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,73 @@
+package config
+
+import (
+	"io/fs"
+	"log/slog"
+	"net/url"
+	"os"
+
+	"github.com/BurntSushi/toml"
+	"github.com/pkg/errors"
+)
+
+type Taxonomy struct {
+	Name string
+	Feed bool
+}
+
+type MenuItem struct {
+	Name string
+	URL  string `toml:"url"`
+}
+
+type URL struct {
+	*url.URL
+}
+
+func (u *URL) UnmarshalText(text []byte) (err error) {
+	u.URL, err = url.Parse(string(text))
+	return err
+}
+
+type Config struct {
+	DefaultLanguage  string `toml:"default_language"`
+	BaseURL          URL    `toml:"base_url"`
+	InjectLiveReload bool
+	Title            string
+	Email            string
+	Description      string
+	DomainStartDate  string `toml:"domain_start_date"`
+	OriginalDomain   string `toml:"original_domain"`
+	Taxonomies       []Taxonomy
+	CSP              *CSP `toml:"content-security-policy"`
+	Extra            struct {
+		Headers map[string]string
+	}
+	Menus map[string][]MenuItem
+}
+
+func getEnvFallback(key, fallback string) string {
+	if value, ok := os.LookupEnv(key); ok {
+		return value
+	} else {
+		return fallback
+	}
+}
+
+func GetConfig() (*Config, error) {
+	config := Config{}
+	slog.Debug("reading config.toml")
+	_, err := toml.DecodeFile("config.toml", &config)
+	if err != nil {
+		var pathError *fs.PathError
+		var tomlError toml.ParseError
+		if errors.As(err, &pathError) {
+			return nil, errors.WithMessage(err, "could not read configuration")
+		} else if errors.As(err, &tomlError) {
+			return nil, errors.WithMessage(err, tomlError.ErrorWithUsage())
+		} else {
+			return nil, errors.Wrap(err, "config error")
+		}
+	}
+	return &config, nil
+}
diff --git a/internal/config/csp.go b/internal/config/csp.go
new file mode 100644
index 0000000..970663c
--- /dev/null
+++ b/internal/config/csp.go
@@ -0,0 +1,45 @@
+package config
+
+// Code generated  DO NOT EDIT.
+
+import (
+	"github.com/crewjam/csp"
+)
+
+type CSP struct {
+	BaseURI                 []string                     `csp:"base-uri" toml:"base-uri"`
+	BlockAllMixedContent    bool                         `csp:"block-all-mixed-content" toml:"block-all-mixed-content"`
+	ChildSrc                []string                     `csp:"child-src" toml:"child-src"`
+	ConnectSrc              []string                     `csp:"connect-src" toml:"connect-src"`
+	DefaultSrc              []string                     `csp:"default-src" toml:"default-src"`
+	FontSrc                 []string                     `csp:"font-src" toml:"font-src"`
+	FormAction              []string                     `csp:"form-action" toml:"form-action"`
+	FrameAncestors          []string                     `csp:"frame-ancestors" toml:"frame-ancestors"`
+	FrameSrc                []string                     `csp:"frame-src" toml:"frame-src"`
+	ImgSrc                  []string                     `csp:"img-src" toml:"img-src"`
+	ManifestSrc             []string                     `csp:"manifest-src" toml:"manifest-src"`
+	MediaSrc                []string                     `csp:"media-src" toml:"media-src"`
+	NavigateTo              []string                     `csp:"navigate-to" toml:"navigate-to"`
+	ObjectSrc               []string                     `csp:"object-src" toml:"object-src"`
+	PluginTypes             []string                     `csp:"plugin-types" toml:"plugin-types"`
+	PrefetchSrc             []string                     `csp:"prefetch-src" toml:"prefetch-src"`
+	Referrer                csp.ReferrerPolicy           `csp:"referrer" toml:"referrer"`
+	ReportTo                string                       `csp:"report-to" toml:"report-to"`
+	ReportURI               string                       `csp:"report-uri" toml:"report-uri"`
+	RequireSRIFor           []csp.RequireSRIFor          `csp:"require-sri-for" toml:"require-sri-for"`
+	RequireTrustedTypesFor  []csp.RequireTrustedTypesFor `csp:"require-trusted-types-for" toml:"require-trusted-types-for"`
+	Sandbox                 csp.Sandbox                  `csp:"sandbox" toml:"sandbox"`
+	ScriptSrc               []string                     `csp:"script-src" toml:"script-src"`
+	ScriptSrcAttr           []string                     `csp:"script-src-attr" toml:"script-src-attr"`
+	ScriptSrcElem           []string                     `csp:"script-src-elem" toml:"script-src-elem"`
+	StyleSrc                []string                     `csp:"style-src" toml:"style-src"`
+	StyleSrcAttr            []string                     `csp:"style-src-attr" toml:"style-src-attr"`
+	StyleSrcElem            []string                     `csp:"style-src-elem" toml:"style-src-elem"`
+	TrustedTypes            []string                     `csp:"trusted-types" toml:"trusted-types"`
+	UpgradeInsecureRequests bool                         `csp:"upgrade-insecure-requests" toml:"upgrade-insecure-requests"`
+	WorkerSrc               []string                     `csp:"worker-src" toml:"worker-src"`
+}
+
+func (c *CSP) String() string {
+	return csp.Header(*c).String()
+}
diff --git a/internal/config/cspgenerator.go b/internal/config/cspgenerator.go
new file mode 100644
index 0000000..0985b9e
--- /dev/null
+++ b/internal/config/cspgenerator.go
@@ -0,0 +1,79 @@
+package config
+
+//go:generate go run ../../cmd/cspgenerator/
+
+import (
+	"fmt"
+	"os"
+	"reflect"
+
+	"github.com/crewjam/csp"
+	"github.com/fatih/structtag"
+)
+
+func GenerateCSP() error {
+	t := reflect.TypeFor[csp.Header]()
+	file, err := os.OpenFile("./csp.go", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	_, err = fmt.Fprintf(file, `package config
+
+// Code generated  DO NOT EDIT.
+
+import (
+	"github.com/crewjam/csp"
+)
+
+`)
+	if err != nil {
+		return err
+	}
+
+	_, err = fmt.Fprintf(file, "type CSP struct {\n")
+	if err != nil {
+		return err
+	}
+
+	for i := 0; i < t.NumField(); i++ {
+		field := t.Field(i)
+		var t reflect.Type
+		if field.Type.Kind() == reflect.Slice {
+			t = field.Type
+		} else {
+			t = field.Type
+		}
+		tags, err := structtag.Parse(string(field.Tag))
+		if err != nil {
+			return err
+		}
+		cspTag, err := tags.Get("csp")
+		if err != nil {
+			return err
+		}
+		tags.Set(&structtag.Tag{
+			Key:  "toml",
+			Name: cspTag.Name,
+		})
+
+		_, err = fmt.Fprintf(file, "\t%-23s %-28s `%s`\n", field.Name, t, tags.String())
+		if err != nil {
+			return err
+		}
+	}
+	_, err = fmt.Fprintln(file, "}")
+	if err != nil {
+		return err
+	}
+
+	_, err = fmt.Fprintln(file, `
+func (c *CSP) String() string {
+	return csp.Header(*c).String()
+}`)
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/internal/server/filemap.go b/internal/server/filemap.go
new file mode 100644
index 0000000..466db49
--- /dev/null
+++ b/internal/server/filemap.go
@@ -0,0 +1,77 @@
+package server
+
+import (
+	"fmt"
+	"hash/fnv"
+	"io"
+	"io/fs"
+	"log"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+type File struct {
+	filename string
+	etag     string
+}
+
+var files = map[string]File{}
+
+func hashFile(filename string) (string, error) {
+	f, err := os.Open(filename)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	hash := fnv.New64a()
+	if _, err := io.Copy(hash, f); err != nil {
+		return "", err
+	}
+	return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil
+}
+
+func registerFile(urlpath string, filepath string) error {
+	if files[urlpath] != (File{}) {
+		log.Printf("registerFile called with duplicate file, urlPath: %s", urlpath)
+		return nil
+	}
+	hash, err := hashFile(filepath)
+	if err != nil {
+		return err
+	}
+	files[urlpath] = File{
+		filename: filepath,
+		etag:     hash,
+	}
+	return nil
+}
+
+func registerContentFiles(root string) 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)
+		}
+		relPath, err := filepath.Rel(root, filePath)
+		if err != nil {
+			return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath)
+		}
+		urlPath, _ := strings.CutSuffix(relPath, "index.html")
+		if !f.IsDir() {
+			slog.Debug("registering file", "urlpath", "/"+urlPath)
+			return registerFile("/"+urlPath, filePath)
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func GetFile(urlPath string) File {
+	return files[urlPath]
+}
diff --git a/internal/server/logging.go b/internal/server/logging.go
new file mode 100644
index 0000000..135f06e
--- /dev/null
+++ b/internal/server/logging.go
@@ -0,0 +1,55 @@
+package server
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+)
+
+type loggingResponseWriter struct {
+	http.ResponseWriter
+	statusCode int
+}
+
+func (lrw *loggingResponseWriter) WriteHeader(code int) {
+	lrw.statusCode = code
+	// avoids warning: superfluous response.WriteHeader call
+	if lrw.statusCode != http.StatusOK {
+		lrw.ResponseWriter.WriteHeader(code)
+	}
+}
+
+func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
+	return &loggingResponseWriter{w, http.StatusOK}
+}
+
+type wrappedHandlerOptions struct {
+	defaultHostname string
+	logger          io.Writer
+}
+
+func wrapHandlerWithLogging(wrappedHandler http.Handler, opts wrappedHandlerOptions) 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.Header.Get("Host")
+		if host == "" {
+			host = opts.defaultHostname
+		}
+		lw := NewLoggingResponseWriter(w)
+		wrappedHandler.ServeHTTP(lw, r)
+		statusCode := lw.statusCode
+		fmt.Fprintf(
+			opts.logger,
+			"%s %s %d %s %s %s\n",
+			scheme,
+			r.Method,
+			statusCode,
+			host,
+			r.URL.Path,
+			lw.Header().Get("Location"),
+		)
+	})
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..cbee989
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,226 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log"
+	"log/slog"
+	"mime"
+	"net"
+	"net/http"
+	"os"
+	"path"
+	"slices"
+	"strings"
+	"time"
+
+	cfg "website/internal/config"
+
+	"github.com/getsentry/sentry-go"
+	sentryhttp "github.com/getsentry/sentry-go/http"
+	"github.com/pkg/errors"
+	"github.com/shengyanli1982/law"
+)
+
+var config *cfg.Config
+
+var (
+	CommitSHA string
+	ShortSHA  string
+)
+
+type Config struct {
+	Production    bool    `conf:"default:false"`
+	InDevServer   bool    `conf:"default:false"`
+	Root          string  `conf:"default:website"`
+	ListenAddress string  `conf:"default:localhost"`
+	Port          string  `conf:"default:3000,short:p"`
+	BaseURL       cfg.URL `conf:"default:http://localhost:3000,short:b"`
+}
+
+type HTTPError struct {
+	Error   error
+	Message string
+	Code    int
+}
+
+type Server struct {
+	*http.Server
+}
+
+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{}) {
+		cPath, differs = path+"/", true
+	}
+	return cPath, differs
+}
+
+func serveFile(w http.ResponseWriter, r *http.Request) *HTTPError {
+	urlPath, shouldRedirect := canonicalisePath(r.URL.Path)
+	if shouldRedirect {
+		http.Redirect(w, r, urlPath, 302)
+		return nil
+	}
+	file := GetFile(urlPath)
+	if file == (File{}) {
+		return &HTTPError{
+			Message: "File not found",
+			Code:    http.StatusNotFound,
+		}
+	}
+	w.Header().Add("ETag", file.etag)
+	w.Header().Add("Vary", "Accept-Encoding")
+	w.Header().Add("Content-Security-Policy", config.CSP.String())
+	for k, v := range config.Extra.Headers {
+		w.Header().Add(k, v)
+	}
+
+	http.ServeFile(w, r, files[urlPath].filename)
+	return nil
+}
+
+type webHandler func(http.ResponseWriter, *http.Request) *HTTPError
+
+func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	defer func() {
+		if fail := recover(); fail != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			slog.Error("runtime panic!", "error", fail)
+		}
+	}()
+	w.Header().Set("Server", fmt.Sprintf("website (%s)", ShortSHA))
+	if err := fn(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)
+		} else {
+			http.Error(w, err.Message, err.Code)
+		}
+	}
+}
+
+var newMIMEs = map[string]string{
+	".xsl": "text/xsl",
+}
+
+func fixupMIMETypes() {
+	for ext, newType := range newMIMEs {
+		if err := mime.AddExtensionType(ext, newType); err != nil {
+			slog.Error("could not update mime type", "ext", ext, "mime", newType)
+		}
+	}
+}
+
+func applyDevModeOverrides(config *cfg.Config) {
+	config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'")
+	config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'")
+}
+
+func New(runtimeConfig *Config) (*Server, error) {
+	fixupMIMETypes()
+
+	var err error
+	config, err = cfg.GetConfig()
+	if err != nil {
+		return nil, errors.WithMessage(err, "error parsing configuration file")
+	}
+	if runtimeConfig.InDevServer {
+		applyDevModeOverrides(config)
+	}
+
+	prefix := path.Join(runtimeConfig.Root, "public")
+	slog.Debug("registering content files", "prefix", prefix)
+	err = registerContentFiles(prefix)
+	if err != nil {
+		return nil, errors.WithMessagef(err, "registering content files")
+	}
+
+	env := "development"
+	if runtimeConfig.Production {
+		env = "production"
+	}
+	err = sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Dsn:              os.Getenv("SENTRY_DSN"),
+		Release:          CommitSHA,
+		Environment:      env,
+	})
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not set up sentry")
+	}
+	defer sentry.Flush(2 * time.Second)
+	sentryHandler := sentryhttp.New(sentryhttp.Options{
+		Repanic: true,
+	})
+
+	top := http.NewServeMux()
+	mux := http.NewServeMux()
+	slog.Debug("binding main handler to", "host", runtimeConfig.BaseURL.Hostname()+"/")
+	mux.Handle(runtimeConfig.BaseURL.Hostname()+"/", webHandler(serveFile))
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		newURL := runtimeConfig.BaseURL.String() + r.URL.String()
+		http.Redirect(w, r, newURL, 301)
+	})
+
+	var logWriter io.Writer
+	if runtimeConfig.Production {
+		logWriter = law.NewWriteAsyncer(os.Stdout, nil)
+	} else {
+		logWriter = os.Stdout
+	}
+	top.Handle("/",
+		sentryHandler.Handle(
+			wrapHandlerWithLogging(mux, wrappedHandlerOptions{
+				defaultHostname: runtimeConfig.BaseURL.Hostname(),
+				logger:          logWriter,
+			}),
+		),
+	)
+	// no logging, no sentry
+	top.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, runtimeConfig.Port)
+	return &Server{
+		&http.Server{
+			Addr:    listenAddress,
+			Handler: top,
+		},
+	}, nil
+}
+
+func (s *Server) Start() error {
+	if err := s.ListenAndServe(); err != http.ErrServerClosed {
+		return err
+	}
+	return nil
+}
+
+func (s *Server) Stop() chan struct{} {
+	slog.Debug("stop called")
+
+	idleConnsClosed := make(chan struct{})
+
+	go func() {
+		slog.Debug("shutting down server")
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		err := s.Server.Shutdown(ctx)
+		slog.Debug("server shut down")
+		if err != nil {
+			// Error from closing listeners, or context timeout:
+			log.Printf("HTTP server Shutdown: %v", err)
+		}
+		close(idleConnsClosed)
+	}()
+
+	return idleConnsClosed
+}
diff --git a/justfile b/justfile
new file mode 100755
index 0000000..9dc57b8
--- /dev/null
+++ b/justfile
@@ -0,0 +1,83 @@
+#! /usr/bin/env -S nix develop . --command just --justfile
+
+fly-system := "x86_64-linux"
+fly-registry := "registry.fly.io/alanpearce-eu"
+docker-tag := env_var_or_default("DOCKER_TAG", `git rev-parse HEAD`)
+version := `sentry-cli releases propose-version`
+environment := "production"
+started-at := `date +%s`
+
+default:
+    @just --list --justfile {{ justfile() }} --unsorted
+
+check:
+    nix flake check . --print-build-logs
+
+check-licenses:
+    nix run nixpkgs#go-licenses check ./...
+
+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 }}
+
+watch-builder: (watch-flake "watchexec -w cmd/build -w content -w templates -r go run ./cmd/build --base-url http://localhost:3000")
+
+generate:
+    go run ./cmd/build --base-url http://localhost:3000
+
+nix-build what:
+    nix build .#{{ what }}
+
+watch-server: (watch-flake "watchexec -r -i content -i templates go run ./cmd/server")
+
+dev:
+    #!/usr/bin/env bash
+    set -euxo pipefail
+    tmp="$(mktemp -d -t website-XXXXXX)" || exit 1
+    echo using temp directory $tmp
+    trap "{ echo cleaning up $tmp; rm -rf \"$tmp\"; }" EXIT
+    go build -o $tmp ./cmd/dev ./cmd/build ./cmd/server
+    "${tmp}/dev" --temp-dir "${tmp}"
+
+watch-dev: (watch-flake "watchexec -r -e go just dev")
+
+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-flags="":
+    skopeo {{ skopeo-flags }} inspect docker-archive:{{ image-path }}
+
+print-docker-tag:
+    @echo {{ fly-registry }}:{{ docker-tag }}
+
+stream-to-registry *skopeo-flags="": sentry-create-release && sentry-finalise-release
+    just docker-stream-fly | gzip --fast | skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive:/dev/stdin docker://{{ fly-registry }}:{{ docker-tag }}
+
+result := `readlink -f result`
+push-to-registry *skopeo-flags="":
+    skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive://{{ result }}  docker://{{ fly-registry }}:{{ docker-tag }}
+
+sentry-create-release:
+    sentry-cli releases new {{ version }}
+
+sentry-finalise-release:
+    sentry-cli releases set-commits {{ version }} --ignore-missing --auto # will not work in CI
+    sentry-cli releases finalize {{ version }}
+
+sentry-create-deploy:
+    sentry-cli releases deploys {{ version }} new --started {{ started-at }} --finished `date +%s` --env {{ environment }}
+
+deploy registry-and-tag=(fly-registry + ":" + docker-tag): && sentry-create-deploy
+    fly deploy --image {{ registry-and-tag }}
diff --git a/netlify/netlify.toml b/netlify/netlify.toml
new file mode 100644
index 0000000..7b203eb
--- /dev/null
+++ b/netlify/netlify.toml
@@ -0,0 +1,14 @@
+[build]
+  base = "netlify"
+  publish = "."
+
+[[redirects]]
+  from = "*"
+  to = "https://alanpearce.eu/:splat"
+  status = 301
+  force = true
+
+[[headers]]
+  for = "/*"
+  [headers.values]
+    cache-control = "max-age=86400"
diff --git a/nix/default.nix b/nix/default.nix
new file mode 100644
index 0000000..de427db
--- /dev/null
+++ b/nix/default.nix
@@ -0,0 +1,102 @@
+{ 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 = [
+        (pkgs.writeTextDir "config.toml" (builtins.readFile ./../config.toml))
+        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/website
+  '';
+  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
new file mode 100644
index 0000000..8c6ac1f
--- /dev/null
+++ b/nix/gomod2nix.toml
@@ -0,0 +1,118 @@
+schema = 3
+
+[mod]
+  [mod."github.com/BurntSushi/toml"]
+    version = "v1.3.2"
+    hash = "sha256-FIwyH67KryRWI9Bk4R8s1zFP0IgKR4L66wNQJYQZLeg="
+  [mod."github.com/PuerkitoBio/goquery"]
+    version = "v1.9.1"
+    hash = "sha256-HlO8KL0FWs7qZk56wcVAn/y080PfK910HyIVo9y9lvM="
+  [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/antage/eventsource"]
+    version = "v0.0.0-20220422142129-c4aae935d5bd"
+    hash = "sha256-5JLLEwW3L3Mt756q3Qw0TKtrNjQ3VtEsV9S2JfdPHy0="
+  [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/bep/godartsass"]
+    version = "v1.2.0"
+    hash = "sha256-kkKRFesHX8Yp1+/L7yFeRqltBRlAVKgdSN4d7Lc/uI8="
+  [mod."github.com/bep/godartsass/v2"]
+    version = "v2.0.0"
+    hash = "sha256-ISvlb0UVikyLGPCrkQus//HVgAcgplQDhdIuTrmToRM="
+  [mod."github.com/bep/golibsass"]
+    version = "v1.1.1"
+    hash = "sha256-rQK/w54sh57GtCG8plKbkFxWBZB0+7RLMvOGCV2jvqY="
+  [mod."github.com/cli/safeexec"]
+    version = "v1.0.1"
+    hash = "sha256-74r/MHyMIxDSGA2D862d/OQ3lYLozxoUIRMyL0n03m8="
+  [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/getsentry/sentry-go"]
+    version = "v0.27.0"
+    hash = "sha256-PTkTzVNogqFA/5rc6INLY6RxK5uR1AoJFOO+pOPdE7Q="
+  [mod."github.com/gobwas/glob"]
+    version = "v0.2.3"
+    hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc="
+  [mod."github.com/gohugoio/hugo"]
+    version = "v0.125.4"
+    hash = "sha256-BHTawZLm/82SXJyGq17pIizqHCG2NJRuqTioKqcu880="
+  [mod."github.com/golang/groupcache"]
+    version = "v0.0.0-20210331224755-41bb18bfe9da"
+    hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0="
+  [mod."github.com/mattn/go-isatty"]
+    version = "v0.0.20"
+    hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
+  [mod."github.com/mitchellh/hashstructure"]
+    version = "v1.1.0"
+    hash = "sha256-dNPVpLRsCa2XZHlCRRtkpBVqb8rpHIocpFPNCqZg2EY="
+  [mod."github.com/otiai10/copy"]
+    version = "v1.14.0"
+    hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A="
+  [mod."github.com/pelletier/go-toml/v2"]
+    version = "v2.2.1"
+    hash = "sha256-gmQ4CTz/MI97D3pYqU7mpxqo8gBTDccQ1Cp0lAMmJUc="
+  [mod."github.com/pkg/errors"]
+    version = "v0.9.1"
+    hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
+  [mod."github.com/shengyanli1982/law"]
+    version = "v0.1.13"
+    hash = "sha256-gjXWxWR6XCpOUYKBzPaObw2hPOmkoVtuHd1aMHm/ljA="
+  [mod."github.com/spf13/afero"]
+    version = "v1.11.0"
+    hash = "sha256-+rV3cDZr13N8E0rJ7iHmwsKYKH+EhV+IXBut+JbBiIE="
+  [mod."github.com/spf13/cast"]
+    version = "v1.6.0"
+    hash = "sha256-hxioqRZfXE0AE5099wmn3YG0AZF8Wda2EB4c7zHF6zI="
+  [mod."github.com/tdewolff/parse/v2"]
+    version = "v2.7.13"
+    hash = "sha256-mG6TO8hcXUmi9yKIogrfoDLWE6i6qs4LTjr7mSmqb7M="
+  [mod."github.com/yuin/goldmark"]
+    version = "v1.7.1"
+    hash = "sha256-3EUgwoZRRs2jNBWSbB0DGNmfBvx7CeAgEwyUdaRaeR4="
+  [mod."golang.org/x/net"]
+    version = "v0.24.0"
+    hash = "sha256-w1c21ljta5wNIyel9CSIn/crPzwOCRofNKhqmfs4aEQ="
+  [mod."golang.org/x/sync"]
+    version = "v0.7.0"
+    hash = "sha256-2ETllEu2GDWoOd/yMkOkLC2hWBpKzbVZ8LhjLu0d2A8="
+  [mod."golang.org/x/sys"]
+    version = "v0.19.0"
+    hash = "sha256-cmuL31TYLJmDm/fDnI2Sn0wB88cpdOHV1+urorsJWx4="
+  [mod."golang.org/x/text"]
+    version = "v0.14.0"
+    hash = "sha256-yh3B0tom1RfzQBf1RNmfdNWF1PtiqxV41jW1GVS6JAg="
+  [mod."google.golang.org/protobuf"]
+    version = "v1.33.0"
+    hash = "sha256-cWwQjtUwSIEkAlAadrlxK1PYZXTRrV4NKzt7xDpJgIU="
+  [mod."gopkg.in/check.v1"]
+    version = "v1.0.0-20201130134442-10cb98267c6c"
+    hash = "sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs="
+  [mod."gopkg.in/yaml.v2"]
+    version = "v2.4.0"
+    hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0="
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..d2c4c45
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,3 @@
+{ system ? builtins.currentSystem }:
+
+(builtins.getFlake (toString ./.)).devShells.${system}.default
diff --git a/static/cv/index.html b/static/cv/index.html
new file mode 100644
index 0000000..4fef4cc
--- /dev/null
+++ b/static/cv/index.html
@@ -0,0 +1,348 @@
+<!doctype html>
+<html>
+  <head>
+    <title>Alan Pearce's Curriculum Vitae</title>
+    <style>
+      body {
+        font-family: Verdana, sans-serif;
+        font-size: small;
+        margin: auto;
+        padding: 1em;
+        max-width: 50rem;
+        text-align: left;
+        background-color: #fff;
+        word-wrap: break-word;
+        overflow-wrap: break-word;
+        line-height: 1.5;
+        color: #444;
+        height: 210mm;
+        width: 297mm;
+      }
+
+      @page {
+        size: A4 portrait;
+      }
+
+      h1,
+      h2,
+      h3,
+      h4,
+      h5,
+      h6,
+      strong,
+      b {
+        color: #222;
+        margin: unset;
+      }
+
+      a {
+        color: #3273dc;
+      }
+
+      .title {
+        color: #222;
+        text-decoration: none;
+        border: 0;
+      }
+
+      time {
+        font-style: italic;
+      }
+
+      nav a {
+        margin-right: 1ex;
+      }
+
+      .tags {
+        padding: unset;
+        font-size: smaller;
+      }
+
+      .tags > li {
+        list-style: none;
+        display: inline-block;
+        padding-right: 1ex;
+      }
+
+      textarea {
+        width: 100%;
+        font-size: 1rem;
+      }
+
+      input {
+        font-size: 1rem;
+      }
+
+      main,
+      article {
+        line-height: 1.6;
+      }
+
+      blockquote {
+        border-left: 1px solid #999;
+        color: #222;
+        padding-left: 20px;
+        font-style: italic;
+      }
+
+      footer {
+        padding: 25px;
+        text-align: center;
+      }
+
+      main {
+        column-count: 2;
+      }
+      main > section {
+        padding-right: 1rem;
+        padding: 1rem 0;
+        border-bottom: 2px solid #999;
+        break-inside: avoid;
+      }
+      section > header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+      }
+      .timeperiod {
+        font-style: italic;
+        font-size: small;
+      }
+
+      ul {
+        padding-left: 0;
+        margin: unset;
+      }
+      ul > li {
+        display: inline-block;
+        font-size: smaller;
+      }
+
+      .links > li {
+        display: block;
+      }
+
+      @media (prefers-color-scheme: dark) {
+        body {
+          background-color: #333;
+          color: #ddd;
+        }
+
+        h1,
+        h2,
+        h3,
+        h4,
+        h5,
+        h6,
+        strong,
+        b,
+        .title {
+          color: #eee;
+        }
+
+        a {
+          color: #8cc2dd;
+        }
+        blockquote {
+          color: #ccc;
+        }
+        section {
+          border-bottom-color: #ccc;
+        }
+      }
+    </style>
+  </head>
+  <body>
+    <header>
+      <h1>Alan Pearce</h1>
+      <h2>Backend & Infrastructure Developer</h2>
+      <h3>Berlin, Germany</h3>
+    </header>
+    <hr />
+    <main>
+      <section>
+        <ul class="links">
+          <li>
+            Email: <a href="mailto:alan@alanpearce.eu">alan@alanpearce.eu</a>
+          </li>
+          <li>Website: <a href="https://alanpearce.eu">alanpearce.eu</a></li>
+          <li>
+            GitHub: <a href="https://github.com/alanpearce">@alanpearce</a>
+          </li>
+          <li>
+            Personal Projects:
+            <a href="https://git.alanpearce.eu">git.alanpearce.eu</a>
+          </li>
+        </ul>
+      </section>
+      <section>
+        <h4>Summary</h4>
+        <p>
+          I care about keeping code and UIs consistent and simple. I also have a
+          strong drive to learn and really enjoy being able to explore new
+          methodologies and languages.
+        </p>
+      </section>
+      <section>
+        <h4>Experience</h4>
+        <header>
+          <h5>Senior Fullstack Developer at SatoshiPay</h5>
+          <span class="timeperiod">2017—2023</span>
+        </header>
+        <ul>
+          <li>Helm</li>
+          <li>Kubernetes</li>
+          <li>GitLab</li>
+          <li>TypeScript</li>
+          <li>PostgreSQL</li>
+        </ul>
+        <p>
+          Principal worker for migration from Docker Cloud to Kubernetes,
+          alongside work on microservices interfacing with distributed ledger
+          APIs. Implemented and maintained GitLab CI/CD pipelines including
+          merge request previews and end-to-end testing. Migrated projects to
+          product-based monorepos.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Fullstack Developer at SpotCap</h5>
+          <span class="timeperiod">2015–2017</span>
+        </header>
+        <ul>
+          <li>NodeJS</li>
+          <li>MySQL</li>
+          <li>Webpack</li>
+          <li>Sails.js</li>
+          <li>Mithril.js</li>
+        </ul>
+        <p>
+          Responsible for banking integration service, implemented parsers and
+          generators for custom text formats (MT940, MT942) using unit tests to
+          verify.
+          <br />
+          Worked on backend credit scoring admin panel, began migration from
+          Sails to SPA using Mithril
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Web Developer at StudentCrowd (Studio-40 spin-off)</h5>
+          <span class="timeperiod">2014–2015</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>ElasticSearch</li>
+          <li>Vagrant</li>
+          <li>Saltstack</li>
+        </ul>
+        <p>
+          Optimised database access and ORM usage. Simplified dev environment
+          setup using Vagrant and Salt. Attended ElasticSearch, LogStash &
+          Kibana training. Worked remotely (60% -> 100%)
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Developer at Studio-40</h5>
+          <span class="timeperiod">2014</span>
+        </header>
+        <ul>
+          <li>Symfony</li>
+          <li>Sylius</li>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>Capistrano</li>
+        </ul>
+        <p>
+          Wrote product CSV importer for Sylius with streaming preview diff
+          feature. Fixed issues with integration of payment provider API
+          including false payment failures. Assisted front-end developers with
+          JavaScript.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Backend Web Developer at Bulb Studios</h5>
+          <span class="timeperiod">2013–2014</span>
+        </header>
+        <ul>
+          <li>Laravel</li>
+          <li>ExpressionEngine</li>
+          <li>Ansible</li>
+          <li>PHP</li>
+          <li>Capistrano</li>
+        </ul>
+        <p>
+          Suggested and implemented switch from Apache to Nginx, enabling a
+          1000x speedup in page loads. Suggested and implemented use of
+          configuration management for server provisioning. Introduced Vagrant
+          to reduce development environment variance and Capistrano for
+          deployment. Created time-basic competition entry API designed for 50k
+          RPM.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>PHP Web Developer at Supplyant</h5>
+          <span class="timeperiod">2012-2013</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>Subversion</li>
+          <li>jQuery</li>
+          <li>HTML</li>
+          <li>CSS</li>
+        </ul>
+        <p>
+          Maintained e-commerce platform and worked on new product management
+          system. Made Entity-Attribute-Value system usable for other database
+          consumers using an SQL view. Recommended use of Mustache templates,
+          which the design team loved
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Web Applications Programmer at ASL Holdings</h5>
+          <span class="timeperiod">2010-2011</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <p>Continued rewrite of SIM management web application</p>
+        </ul>
+      </section>
+      <section>
+        <h4>Relevant Education</h4>
+        <div>
+          <header>
+            <h5>CodeSchool</h5>
+            <span class="timeperiod">2014</span>
+          </header>
+          <ul>
+            <li>Ruby</li>
+            <li>JavaScript</li>
+            <li>CoffeeScript</li>
+            <li>EmberJS</li>
+            <li>BackboneJS</li>
+          </ul>
+        </div>
+        <div>
+          <header>
+            <h5>Computing A Level at Northampton College</h5>
+            <span class="timeperiod">2008-2010</span>
+          </header>
+        </div>
+        <ul>
+          <li>Pascal</li>
+          <li>PHP</li>
+          <li>SQL</li>
+          <li>HTML</li>
+          <li>CSS</li>
+        </ul>
+      </section>
+    </main>
+  </body>
+</html>
diff --git a/static/robots.txt b/static/robots.txt
index ef30e6f..a0e9740 100644
--- a/static/robots.txt
+++ b/static/robots.txt
@@ -1,7 +1,4 @@
 User-agent: *
 Disallow:
 Host: alanpearce.eu
-Sitemap: https://alanpearce.eu/sitemap.xml
 
-User-agent: googlebot
-Disallow: /
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..eade0f9
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,38 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <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
new file mode 100644
index 0000000..81c9a76
--- /dev/null
+++ b/templates/atom.xml
@@ -0,0 +1,48 @@
+<?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
new file mode 100644
index 0000000..737b99d
--- /dev/null
+++ b/templates/count.html
@@ -0,0 +1,6 @@
+<body>
+  <script data-goatcounter="https://alanpearce-eu.goatcounter.com/count" async src="https://gc.zgo.at/count.js"></script>
+  <noscript>
+    <img src="https://alanpearce-eu.goatcounter.com/count?p=/INSERT-PAGE-HERE" />
+  </noscript>
+</body>
diff --git a/templates/dev.html b/templates/dev.html
new file mode 100644
index 0000000..0ca383e
--- /dev/null
+++ b/templates/dev.html
@@ -0,0 +1,8 @@
+<body>
+  <script defer>
+    new EventSource("/_/reload").onmessage = event => {
+      console.log("got message", event)
+      window.location.reload()
+    };
+  </script>
+</body>
diff --git a/templates/feed-styles.xsl b/templates/feed-styles.xsl
new file mode 100644
index 0000000..5953f89
--- /dev/null
+++ b/templates/feed-styles.xsl
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<xsl:stylesheet
+  version="3.0"
+  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns:atom="http://www.w3.org/2005/Atom"
+>
+  <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
+  <xsl:template match="/">
+    <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+      <head>
+        <title>RSS Feed | <xsl:value-of select="/atom:feed/atom:title"/></title>
+        <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>
+      </head>
+      <body>
+        <main>
+          <div class="helptext">
+            <strong>This is an RSS feed</strong>. Subscribe by copying the URL
+            from the address bar into your newsreader. Visit
+            <a href="https://aboutfeeds.com">About Feeds</a>
+            to learn more and get started. It's free.
+          </div>
+          <div>
+            <h1>
+              <!-- https://commons.wikimedia.org/wiki/File:Feed-icon.svg -->
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                version="1.1"
+                style="width: 1.5ex; height: 1.5ex"
+                viewBox="0 0 256 256"
+              >
+                <rect width="256" height="256" x="0" y="0" fill="#7F7F7F" />
+                <rect width="246" height="246" x="5" y="5" fill="#A0A0A0" />
+                <rect width="236" height="236" x="10" y="10" fill="#A6A6A6" />
+                <circle cx="68" cy="189" r="24" fill="#FFF" />
+                <path
+                  d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z"
+                  fill="#FFF"
+                />
+                <path
+                  d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z"
+                  fill="#FFF"
+                />
+              </svg>
+              RSS Feed Preview |
+              <span>
+                <xsl:value-of select="/atom:feed/atom:title" />
+              </span>
+            </h1>
+            <nav>
+              <a>
+                <xsl:attribute name="href">
+                  <xsl:value-of select="/atom:feed/atom:link[1]/@href" />
+                </xsl:attribute>
+                Visit Website
+              </a>
+            </nav>
+            <ul class="h-feed">
+              <xsl:for-each select="/atom:feed/atom:entry">
+                <li class="h-entry">
+                  <span>
+                    <time class="dt-published">
+                      <xsl:value-of select="substring(atom:updated, 0, 11)" />
+                    </time>
+                  </span>
+                  <a class="p-name u-url">
+                    <xsl:attribute name="href">
+                      <xsl:value-of select="atom:link/@href" />
+                    </xsl:attribute>
+                    <xsl:value-of select="atom:title" />
+                  </a>
+                </li>
+              </xsl:for-each>
+            </ul>
+          </div>
+        </main>
+      </body>
+    </html>
+  </xsl:template>
+</xsl:stylesheet>
diff --git a/templates/feed.xml b/templates/feed.xml
new file mode 100644
index 0000000..ddc90dd
--- /dev/null
+++ b/templates/feed.xml
@@ -0,0 +1,24 @@
+<?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
new file mode 100644
index 0000000..d256e8c
--- /dev/null
+++ b/templates/homepage.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <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/list.html b/templates/list.html
new file mode 100644
index 0000000..74d6576
--- /dev/null
+++ b/templates/list.html
@@ -0,0 +1,53 @@
+<!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>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <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/post.html b/templates/post.html
new file mode 100644
index 0000000..7574a1f
--- /dev/null
+++ b/templates/post.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title></title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <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/style.css b/templates/style.css
new file mode 100644
index 0000000..8d21237
--- /dev/null
+++ b/templates/style.css
@@ -0,0 +1,195 @@
+body {
+  font-family: Verdana, sans-serif;
+  margin: auto;
+  padding: 1em;
+  max-width: 50rem;
+  text-align: left;
+  background-color: #fff;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  line-height: 1.5;
+  color: #444;
+}
+
+.skip {
+  position: absolute;
+  top: -3em;
+  background: #fff;
+}
+.skip:focus {
+  top: 0;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+strong,
+b {
+  color: #222;
+}
+
+a {
+  color: #3273dc;
+}
+
+.title {
+  color: #222;
+  text-decoration: none;
+  border: 0;
+}
+
+.filter {
+  margin-bottom: 0;
+}
+
+time {
+  font-style: italic;
+}
+
+nav a {
+  margin-right: 1ex;
+}
+
+.tags {
+  padding: unset;
+  font-size: small;
+}
+
+.tags ul {
+  display: inline-block;
+}
+
+.tags li {
+  list-style: none;
+  display: inline-block;
+  padding-right: 1ex;
+}
+
+textarea {
+  width: 100%;
+  font-size: 1rem;
+}
+
+input {
+  font-size: 1rem;
+}
+
+main,
+article {
+  line-height: 1.6;
+}
+
+table {
+  width: 100%;
+}
+
+img {
+  max-width: 100%;
+}
+
+code {
+  padding: 2px 5px;
+  background-color: #f2f2f2;
+}
+
+pre code {
+  color: #222;
+  display: block;
+  padding: 20px;
+  white-space: pre-wrap;
+  font-size: 0.875rem;
+  overflow-x: auto;
+}
+
+div.highlight pre {
+  background-color: initial;
+  color: initial;
+}
+
+div.highlight code {
+  background-color: unset;
+  color: unset;
+}
+
+blockquote {
+  border-left: 1px solid #999;
+  color: #222;
+  padding-left: 20px;
+  font-style: italic;
+}
+
+footer {
+  padding: 25px;
+  text-align: center;
+}
+
+.helptext {
+  color: #777;
+  font-size: small;
+}
+
+/* blog posts */
+ul.h-feed {
+  list-style-type: none;
+  padding: unset;
+}
+
+ul.h-feed li {
+  display: flex;
+}
+
+ul.h-feed li span {
+  flex: 0 0 130px;
+}
+
+ul.h-feed li a:visited {
+  color: #8b6fcb;
+}
+
+@media (prefers-color-scheme: dark) {
+  body {
+    background-color: #333;
+    color: #ddd;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  strong,
+  b,
+  .title {
+    color: #eee;
+  }
+
+  a {
+    color: #8cc2dd;
+  }
+
+  code {
+    background-color: #777;
+  }
+
+  pre code {
+    color: #ddd;
+  }
+
+  blockquote {
+    color: #ccc;
+  }
+
+  textarea,
+  input {
+    background-color: #252525;
+    color: #ddd;
+  }
+
+  .helptext {
+    color: #aaa;
+  }
+}
diff --git a/templates/tags.html b/templates/tags.html
new file mode 100644
index 0000000..79c1c09
--- /dev/null
+++ b/templates/tags.html
@@ -0,0 +1,43 @@
+<!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>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <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/themes/xmin/static/css/style.css b/themes/xmin/static/css/style.css
deleted file mode 100644
index e605297..0000000
--- a/themes/xmin/static/css/style.css
+++ /dev/null
@@ -1,75 +0,0 @@
-body {
-  font-family: sans-serif;
-  line-height: 1.5em;
-  margin: auto;
-  max-width: 800px;
-  padding: 1em;
-}
-
-/* header and footer areas */
-nav > ul { padding: 0; }
-nav > ul > li { display: inline-block; }
-article > header, nav > ul a {
-  background: #eee;
-  border-radius: 5px;
-  padding: 5px;
-  text-decoration: none;
-}
-.terms { font-size: .9em; }
-nav > ul, article > header, footer { text-align: center; }
-.title { font-size: 1.1em; }
-footer a { text-decoration: none; }
-hr {
-  border-style: dashed;
-  color: #ddd;
-}
-body > nav {
-  border-bottom: 1px solid #ddd;
-}
-body > footer {
-  border-top: 1px solid #ddd;
-}
-
-/* code */
-pre {
-  border: 1px solid #ddd;
-  overflow-x: auto;
-  padding: 1em;
-}
-code { background: #f9f9f9; }
-pre code { background: none; }
-
-/* misc elements */
-img, iframe, video { max-width: 100%; }
-main { hyphens: auto; }
-blockquote {
-  background: #f9f9f9;
-  border-left: 5px solid #ccc;
-  padding: 3px 1em 3px;
-}
-
-table thead th { border-bottom: 1px solid #ddd; }
-th, td { padding: 5px; }
-thead, tfoot, tr:nth-child(even) { background: #eee; }
-.hl { background-color: #ffc; }
-
-@media (prefers-color-scheme: dark) {
-	body {
-		background-color: #111;
-		color: white;
-	}
-	article > header, nav > ul a {
-		background: #222;
-	}
-	a {
-		color: #C4D4EE;
-	}
-	a:visited {
-		color: #CEDEE0;
-	}
-	code { background-color: #444; }
-	thead, tfoot, tr:nth-child(even) { background: #222; }
-	.hl {
-		background-color: #555;
-	}
-}
diff --git a/themes/xmin/templates/base.html b/themes/xmin/templates/base.html
deleted file mode 100644
index 5942342..0000000
--- a/themes/xmin/templates/base.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html lang="{{ lang }}">
-  <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title {%- if current_path == '/' %} class="p-name"{% endif %}>{% block title %}{{ section.title }} | {{ config.title }}{% endblock %}</title>
-    <link rel="stylesheet" href="/css/style.css" />
-    {%- if config.generate_feed %}
-    {%- block rss %}
-    <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="RSS" href="{{ get_url(path=config.feed_filename) | safe }}">
-    {%- endblock %}
-    {%- endif %}
-  </head>
-  <body>
-    <nav>
-      <ul>
-        {%- for item in config.extra.menu.main %}
-          <li><a {%- if item.url == "/" %} class="author"{% endif %} href="{{ item.url | safe }}">{{ item.name }}</a></li>
-        {%- endfor %}
-      </ul>
-    </nav>
-    {% block main %}{% endblock %}
-    <footer>{{ config.extra.footer | safe }}</footer>
-  </body>
-</html>
diff --git a/themes/xmin/templates/categories/list.html b/themes/xmin/templates/categories/list.html
deleted file mode 120000
index e0e4e08..0000000
--- a/themes/xmin/templates/categories/list.html
+++ /dev/null
@@ -1 +0,0 @@
-../tags/list.html
\ No newline at end of file
diff --git a/themes/xmin/templates/categories/single.html b/themes/xmin/templates/categories/single.html
deleted file mode 120000
index 86f5e80..0000000
--- a/themes/xmin/templates/categories/single.html
+++ /dev/null
@@ -1 +0,0 @@
-../tags/single.html
\ No newline at end of file
diff --git a/themes/xmin/templates/index.html b/themes/xmin/templates/index.html
deleted file mode 100644
index 23ec4cd..0000000
--- a/themes/xmin/templates/index.html
+++ /dev/null
@@ -1,36 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-<main class="h-card">
-  <h1 class="p-name">{{ config.title }}</h1>
-  {{ section.content | safe }}
-  <section>
-    <h2>Latest Posts</h2>
-    <ul class="h-feed">
-      {%- for page in section.pages | slice(end=3) %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-  <section>
-    <h2>Elsewhere on the Internet</h2>
-    <ul>
-      {%- for item in config.extra.menu.contact %}
-        <li>
-          {%- if item.url is starting_with("mailto:") %}
-            <a href="{{ item.url | safe }}" class="u-email email" rel="me">{{ item.name }}</a>
-          {%- else %}
-            <a href="{{ item.url | safe }}" class="u-url url" rel="me">{{ item.name }}</a>
-          {%- endif %}
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-  <footer>
-    GPG Key: <a href="{{ config.extra.gpg_url | safe }}" rel="u-key pgpkey">{{ config.extra.gpg_fingerprint }}</a>
-  </footer>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/page.html b/themes/xmin/templates/page.html
deleted file mode 100644
index f32a6fc..0000000
--- a/themes/xmin/templates/page.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-{{- page.title -}}
-{% endblock %}
-
-{% block main %}
-<article class="h-entry">
-  <header>
-    <h1><span class="title p-name">{{ page.title }}</span></h1>
-    <time class="dt-published" datetime="{{ page.date | date(format='%+') }}">{{ page.date | date(format="%F") }}</time>
-    <p class="terms">
-      {%- if page.taxonomies %}
-      {%- for name, taxon in page.taxonomies %}
-      {{ name | capitalize }}:
-      {%- for item in taxon %}
-      <a class="p-category" href="{{ get_taxonomy_url(kind=name, name=item) }}">{{ item }}</a>
-      {%- endfor %}
-      {%- endfor %}
-      {%- endif %}
-    </p>
-  </header>
-
-  <main class="e-content">
-    {{ page.content | safe }}
-  </main>
-</article>
-{% endblock %}
diff --git a/themes/xmin/templates/section.html b/themes/xmin/templates/section.html
deleted file mode 100644
index e61566f..0000000
--- a/themes/xmin/templates/section.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-<main>
-  <h1>{{ section.title }}</h1>
-  {{ section.content }}
-  <section>
-    <ul>
-      {% for page in section.pages %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a>
-        </li>
-      {% endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/tags/list.html b/themes/xmin/templates/tags/list.html
deleted file mode 100644
index ee60c39..0000000
--- a/themes/xmin/templates/tags/list.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ taxonomy.name | capitalize }}{% endblock %}
-
-{% block main %}
-<main>
-  <h1>{{ taxonomy.name | capitalize }}</h1>
-  <section>
-    <ul>
-      {%- for term in terms %}
-        <li>
-          <a href="{{ term.permalink }}">{{ term.name }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/tags/single.html b/themes/xmin/templates/tags/single.html
deleted file mode 100644
index 25dde54..0000000
--- a/themes/xmin/templates/tags/single.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends "base.html" %}
-
-{% block rss %}
-  {% set rss_path = "tags/" ~ term.name ~ "/atom.xml" %}
-  <link rel="alternate" type="application/atom+xml" title="RSS" href="{{ get_url(path=rss_path, trailing_slash=false) | safe }}">
-
-{% endblock %}
-
-{% block title %}{{ taxonomy.name | capitalize }}: {{ term.name }} | {{ config.title }}{% endblock %}
-
-{% block main %}
-<main>
-  <h1>{{ taxonomy.name | capitalize }}: {{ term.name }}</h1>
-  <section>
-    <ul class="h-feed">
-      {%- for page in term.pages %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.permalink | safe }}">{{ page.title }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/theme.toml b/themes/xmin/theme.toml
deleted file mode 100644
index 99884b9..0000000
--- a/themes/xmin/theme.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-name = "xmin"
-description = "XMin is a Hugo theme written by Yihui Xie in about four hours"
-license = "MIT"
-
-[author]
-name = "Alan Pearce"
-homepage = "https://www.alanpearce.eu"
-
-[original]
-author = "yihui"
-homepage = "https://yihui.org"
-repo = "https://github.com/yihui/hugo-xmin"
\ No newline at end of file