about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.dir-locals.el2
-rw-r--r--.dockerignore169
-rw-r--r--.envrc8
-rw-r--r--.gitignore190
-rw-r--r--.gitmodules3
-rw-r--r--Dockerfile67
-rw-r--r--LICENSE19
-rw-r--r--Makefile79
-rw-r--r--README.md15
-rwxr-xr-xbin/build.ts30
-rwxr-xr-xbun.lockbbin14725 -> 0 bytes
-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.toml51
-rw-r--r--content/LICENSE395
-rw-r--r--content/post/nixos-on-nanopi-r5s.md2
-rw-r--r--content/post/self-hosted-git.md2
-rw-r--r--flake.lock68
-rw-r--r--flake.nix75
-rw-r--r--fly.toml22
-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--package.json26
-rw-r--r--shell.nix3
-rw-r--r--src/app.ts261
-rw-r--r--src/config.ts5
-rw-r--r--src/index.ts25
-rw-r--r--src/posts.ts63
-rw-r--r--src/templates.ts259
-rw-r--r--templates/404.html7
-rw-r--r--templates/count.html6
-rw-r--r--templates/dev.html8
-rw-r--r--templates/homepage.html19
-rw-r--r--templates/list.html16
-rw-r--r--templates/post.html43
-rw-r--r--templates/style.css10
-rw-r--r--templates/tags.html10
-rw-r--r--test/index.test.ts97
-rw-r--r--tsconfig.json25
55 files changed, 3086 insertions, 1420 deletions
diff --git a/.dir-locals.el b/.dir-locals.el
deleted file mode 100644
index d5837e0..0000000
--- a/.dir-locals.el
+++ /dev/null
@@ -1,2 +0,0 @@
-((nil . ((compile-command . "make -B")
-         (web-mode-engine . "go"))))
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index f81d56e..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,169 +0,0 @@
-# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
-
-# Logs
-
-logs
-_.log
-npm-debug.log_
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-.pnpm-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
-
-report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
-
-# Runtime data
-
-pids
-_.pid
-_.seed
-\*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-
-lib-cov
-
-# Coverage directory used by tools like istanbul
-
-coverage
-\*.lcov
-
-# nyc test coverage
-
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-
-bower_components
-
-# node-waf configuration
-
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-
-build/Release
-
-# Dependency directories
-
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-
-web_modules/
-
-# TypeScript cache
-
-\*.tsbuildinfo
-
-# Optional npm cache directory
-
-.npm
-
-# Optional eslint cache
-
-.eslintcache
-
-# Optional stylelint cache
-
-.stylelintcache
-
-# Microbundle cache
-
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-
-.node_repl_history
-
-# Output of 'npm pack'
-
-\*.tgz
-
-# Yarn Integrity file
-
-.yarn-integrity
-
-# dotenv environment variable files
-
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-
-.cache
-.parcel-cache
-
-# Next.js build output
-
-.next
-out
-
-# Nuxt.js build / generate output
-
-.nuxt
-dist
-
-# Gatsby files
-
-.cache/
-
-# Comment in the public line in if your project uses Gatsby and not Next.js
-
-# https://nextjs.org/blog/next-9-1#public-directory-support
-
-# public
-
-# vuepress build output
-
-.vuepress/dist
-
-# vuepress v2.x temp and cache directory
-
-.temp
-.cache
-
-# Docusaurus cache and generated files
-
-.docusaurus
-
-# Serverless directories
-
-.serverless/
-
-# FuseBox cache
-
-.fusebox/
-
-# DynamoDB Local files
-
-.dynamodb/
-
-# TernJS port file
-
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-
-.vscode-test
-
-# yarn v2
-
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.\*
diff --git a/.envrc b/.envrc
index 3550a30..51aa811 100644
--- a/.envrc
+++ b/.envrc
@@ -1 +1,7 @@
-use flake
+layout go
+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 5df5eb6..2932e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,173 +1,31 @@
-# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+# 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
 
-# Logs
+# Ignore everything
+*
 
-logs
-_.log
-npm-debug.log_
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-.pnpm-debug.log*
+# But not these files...
+!.gitignore
 
-# Diagnostic reports (https://nodejs.org/api/report.html)
+!*.go
+!go.sum
+!go.mod
 
-report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+!README.md
+!LICENSE
 
-# Runtime data
+!.envrc
+!justfile
+!*.nix
+!*.toml
+!/flake.lock
 
-pids
-_.pid
-_.seed
-\*.pid.lock
+!/content/**/*.md
+!/static/**/*
+!/templates/*
 
-# Directory for instrumented libs generated by jscoverage/JSCover
-
-lib-cov
-
-# Coverage directory used by tools like istanbul
-
-coverage
-\*.lcov
-
-# nyc test coverage
-
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-
-bower_components
-
-# node-waf configuration
-
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-
-build/Release
-
-# Dependency directories
-
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-
-web_modules/
-
-# TypeScript cache
-
-\*.tsbuildinfo
-
-# Optional npm cache directory
-
-.npm
-
-# Optional eslint cache
-
-.eslintcache
-
-# Optional stylelint cache
-
-.stylelintcache
-
-# Microbundle cache
-
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-
-.node_repl_history
-
-# Output of 'npm pack'
-
-\*.tgz
-
-# Yarn Integrity file
-
-.yarn-integrity
-
-# dotenv environment variable files
-
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-
-.cache
-.parcel-cache
-
-# Next.js build output
-
-.next
-out
-
-# Nuxt.js build / generate output
-
-.nuxt
-dist
-
-# Gatsby files
-
-.cache/
-
-# Comment in the public line in if your project uses Gatsby and not Next.js
-
-# https://nextjs.org/blog/next-9-1#public-directory-support
-
-# public
-
-# vuepress build output
-
-.vuepress/dist
-
-# vuepress v2.x temp and cache directory
-
-.temp
-.cache
-
-# Docusaurus cache and generated files
-
-.docusaurus
-
-# Serverless directories
-
-.serverless/
-
-# FuseBox cache
-
-.fusebox/
-
-# DynamoDB Local files
-
-.dynamodb/
-
-# TernJS port file
-
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-
-.vscode-test
-
-# yarn v2
-
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.\*
-/.direnv/
-/public/
-/.compressstamp
-/.formatstamp
+# ...even if they are in subdirectories
+!*/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index b7aeba2..0000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "themes/bear"]
-	path = themes/bear
-	url = ssh://git.alanpearce.eu/zola-bearblog
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 26d170a..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,67 +0,0 @@
-# syntax = docker/dockerfile:1
-
-# Adjust BUN_VERSION as desired
-ARG BUN_VERSION=1.0.12
-FROM oven/bun:${BUN_VERSION} as base
-
-LABEL fly_launch_runtime="Bun"
-
-# Bun app lives here
-WORKDIR /app
-
-# Set production environment
-ENV NODE_ENV="production"
-
-# Throw-away build stage to reduce size of final image
-FROM base as build
-
-# # Install packages needed to build node modules
-# RUN apt-get update -qq && \
-#     apt-get install -y build-essential pkg-config python-is-python3
-
-# Install node modules
-COPY --link bun.lockb package.json ./
-RUN bun install --ci
-
-# Copy application code
-COPY --link config.toml config.toml
-COPY --link src src
-COPY --link bin bin
-COPY --link content content
-COPY --link templates templates
-COPY --link static static
-
-RUN ./bin/build.ts && rm -r node_modules
-
-ENV NODE_ENV=production
-RUN [ "bun", "install", "--production", "--ci" ]
-
-FROM alpine:20230901 as postprocess
-
-RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
-
-RUN apk add --no-cache prettier@testing make fd brotli gzip zstd
-
-WORKDIR /app
-
-COPY --link Makefile ./
-COPY --link config.toml ./
-COPY --from=build /app/public public
-
-RUN make format
-RUN make -j4 compress
-
-# Final stage for app image
-FROM base
-
-# Copy built application
-COPY config.toml /app/
-COPY --from=build /app /app
-COPY --from=postprocess /app/public /app/
-
-# Start the server by default, this can be overwritten at runtime
-EXPOSE 3000
-EXPOSE 9091
-
-ENV NODE_ENV=production
-CMD [ "bun", "run", "src/index.ts" ]
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/Makefile b/Makefile
deleted file mode 100644
index ef95ece..0000000
--- a/Makefile
+++ /dev/null
@@ -1,79 +0,0 @@
-.SHELLFLAGS := -eu -o pipefail -c
-.ONESHELL:
-.DELETE_ON_ERROR:
-
-brotli := @brotli --force --no-copy-stat
-gzip := @gzip --best --keep --force
-zstd := @zstd --force --no-pass-through -19 --quiet --stdout
-pr   := @printf "%4s %s\n"
-
-format: .formatstamp
-
-.formatstamp:
-	@echo "Formatting HTML..."
-	@prettier --write --parser html --print-width 120 "public/**/*.html" > /dev/null
-	@touch .formatstamp
-
-clean:
-	rm -rf ./public
-	rm -rf ./.*stamp
-
-to_compress := $(shell fd . public --type=file -e html -e xml -e txt -e asc)
-brotli_files := $(patsubst %, %.br, $(to_compress))
-gzip_files := $(patsubst %, %.gz, $(to_compress))
-zstd_files := $(patsubst %, %.zst, $(to_compress))
-
-%.html.br: %.html
-	@echo $@
-	$(brotli) $<
-
-%.xml.br: %.xml
-	@echo $@
-	$(brotli) $<
-
-%.txt.br: %.txt
-	@echo $@
-	$(brotli) $<
-
-%.asc.br: %.asc
-	@echo $@
-	$(brotli) $<
-
-%.html.gz: %.html
-	@echo $@
-	$(gzip) $<
-
-%.xml.gz: %.xml
-	$(gzip) $<
-
-%.txt.gz: %.txt
-	@echo $@
-	$(gzip) $<
-
-%.asc.gz: %.asc
-	@echo $@
-	$(gzip) $<
-
-%.html.zst: %.html
-	@echo $@
-	$(zstd) $< > $@
-
-%.xml.zst: %.xml
-	@echo $@
-	$(zstd) $< > $@
-
-%.txt.zst: %.txt
-	@echo $@
-	$(zstd) $< > $@
-
-%.asc.zst: %.asc
-	@echo $@
-	$(zstd) $< > $@
-
-compress: .compressstamp
-
-.compressstamp: .formatstamp $(brotli_files) $(gzip_files) $(zstd_files)
-	@echo "Compressing output files..."
-	@touch .compressstamp
-
-.PHONY: all clean build format compress deploy
diff --git a/README.md b/README.md
index 34f3f42..bc27bc4 100644
--- a/README.md
+++ b/README.md
@@ -6,18 +6,3 @@
 2. Dynamic web server capable of generating Zola-based websites
 3. More indieweb features
 
-## Installing
-
-To install dependencies:
-
-```bash
-bun install
-```
-
-To run:
-
-```bash
-bun run src/index.ts
-```
-
-This project was created using `bun init` in bun v0.8.1. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
diff --git a/bin/build.ts b/bin/build.ts
deleted file mode 100755
index 2b76a94..0000000
--- a/bin/build.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/usr/bin/env bun
-import fs from "node:fs";
-import log from "loglevel";
-
-import generateSite from "../src/templates";
-
-log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc);
-
-fs.mkdirSync("public", { recursive: true });
-log.info("Generating site...");
-try {
-  await generateSite();
-} catch (error) {
-  log.error("Error generating site", error);
-  process.exit(1);
-}
-
-log.info("Copying static files...");
-if (!fs.existsSync("static")) {
-  log.error("static directory not found");
-  process.exit(1);
-}
-fs.readdirSync("static").map((file) => {
-  log.debug(`Copying static/${file}`);
-  fs.cpSync(`static/${file}`, `public/${file}`, {
-    dereference: true,
-    recursive: true,
-    preserveTimestamps: true,
-  });
-});
diff --git a/bun.lockb b/bun.lockb
deleted file mode 100755
index 1cb7655..0000000
--- a/bun.lockb
+++ /dev/null
Binary files differdiff --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 8ff0bd4..e996899 100644
--- a/config.toml
+++ b/config.toml
@@ -1,39 +1,45 @@
 default_language = "en-GB"
 base_url = "https://alanpearce.eu"
-redirect_other_hostnames = true
 
 title = "Alan Pearce"
 email = "alan@alanpearce.eu"
 description = "Developer, Emacs User"
 
-generate_feed = true
-
 domain_start_date = "2014-06-07"
 original_domain = "alanpearce.eu"
 
-theme = "bear"
-
-[markdown]
-highlight_code = true
-highlight_theme = "idle"
-
 [[taxonomies]]
 name = "tags"
 feed = true
 
-[extra]
-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"
-hide_made_with_line = true
-date_format = "%F"
-webserver_sends_csp_headers = true
+[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"
-content-security-policy = "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'self'; style-src 'unsafe-inline'; form-action 'none'; base-uri 'self'; frame-ancestors https://kagi.com;"
 
 [[menus.main]]
     name = "Home"
@@ -52,15 +58,18 @@ content-security-policy = "default-src 'none'; img-src 'self'; object-src 'none'
     url = "https://git.alanpearce.eu"
 
 [[menus.me]]
+  name = "Sourcehut"
+  url = "https://sr.ht/~alanpearce/"
+[[menus.me]]
   name = "Codeberg"
   url = "https://codeberg.org/alanpearce"
 [[menus.me]]
-  name = "GitHub"
-  url = "https://github.com/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]]
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/post/nixos-on-nanopi-r5s.md b/content/post/nixos-on-nanopi-r5s.md
index 825d6b4..185bd30 100644
--- a/content/post/nixos-on-nanopi-r5s.md
+++ b/content/post/nixos-on-nanopi-r5s.md
@@ -2,7 +2,7 @@
 title = "Running NixOS on a NanoPi R5S"
 date = 2023-07-30T08:51:46Z
 [taxonomies]
-tags = ["NixOS", "home-networking", "infrastructure"]
+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)).
diff --git a/content/post/self-hosted-git.md b/content/post/self-hosted-git.md
index d0ac370..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]:@/post/repository-management-with-ghq.md "Repository management with ghq"
+[using-ghq]:/post/repository-management-with-ghq/ "Repository management with ghq"
diff --git a/flake.lock b/flake.lock
index c582ce6..a9eab85 100644
--- a/flake.lock
+++ b/flake.lock
@@ -16,46 +16,36 @@
         "type": "github"
       }
     },
-    "flake-utils": {
+    "gomod2nix": {
       "inputs": {
-        "systems": "systems"
+        "flake-utils": [
+          "utils"
+        ],
+        "nixpkgs": [
+          "nixpkgs"
+        ]
       },
       "locked": {
-        "lastModified": 1694529238,
-        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
+        "lastModified": 1710154385,
+        "narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=",
+        "owner": "tweag",
+        "repo": "gomod2nix",
+        "rev": "872b63ddd28f318489c929d25f1f0a3c6039c971",
         "type": "github"
       },
       "original": {
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "type": "github"
-      }
-    },
-    "flockenzeit": {
-      "locked": {
-        "lastModified": 1671185345,
-        "narHash": "sha256-+5IWi+iJAYcRxvLN15hKO2hVwNokfN3U+lvWf/zFtCg=",
-        "owner": "balsoft",
-        "repo": "Flockenzeit",
-        "rev": "90abba65671690d95b5d28ce6dd8de7959aa1339",
-        "type": "github"
-      },
-      "original": {
-        "owner": "balsoft",
-        "repo": "Flockenzeit",
+        "owner": "tweag",
+        "repo": "gomod2nix",
         "type": "github"
       }
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1700538105,
-        "narHash": "sha256-uZhOCmwv8VupEmPZm3erbr9XXmyg7K67Ul3+Rx2XMe0=",
+        "lastModified": 1714213793,
+        "narHash": "sha256-Yg5D5LhyAZvd3DZrQQfJAVK8K3TkUYKooFtH1ulM0mw=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "51a01a7e5515b469886c120e38db325c96694c2f",
+        "rev": "d6f6eb2a984f2ba9a366c31e4d36d65465683450",
         "type": "github"
       },
       "original": {
@@ -68,9 +58,9 @@
     "root": {
       "inputs": {
         "flake-compat": "flake-compat",
-        "flake-utils": "flake-utils",
-        "flockenzeit": "flockenzeit",
-        "nixpkgs": "nixpkgs"
+        "gomod2nix": "gomod2nix",
+        "nixpkgs": "nixpkgs",
+        "utils": "utils"
       }
     },
     "systems": {
@@ -87,6 +77,24 @@
         "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",
diff --git a/flake.nix b/flake.nix
index dec5e4d..85f7915 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,54 +1,59 @@
 {
   description = "My website, alanpearce.eu";
   inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
-  inputs.flockenzeit.url = "github:balsoft/Flockenzeit";
-  inputs.flake-utils.url = "github:numtide/flake-utils";
+  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, flockenzeit, flake-utils, ... }:
-    flake-utils.lib.eachDefaultSystem
+  outputs = { self, nixpkgs, utils, gomod2nix, ... }:
+    utils.lib.eachDefaultSystem
       (system:
         let
-          pkgs = nixpkgs.legacyPackages.${system};
-          nativeBuildInputs = with pkgs; [
-            nodePackages.prettier
-            fd
-            brotli
-            gzip
-            zstd
-            git
+          pkgs = import nixpkgs {
+            inherit system;
+            overlays = [ gomod2nix.overlays.default ];
+          };
+          packages = import ./nix/default.nix {
+            inherit pkgs self;
+          };
+          commonShellPackages = with pkgs; [
+            just
+            skopeo
+            flyctl
           ];
         in
-        rec {
-          packages = {
-            default = pkgs.stdenv.mkDerivation {
-              name = "alanpearce.eu";
-              src = self;
-
-              enableParallelBuilding = true;
-              makeFlags = [ "PREFIX=$(out)/public" ];
-
-              inherit nativeBuildInputs;
-
-              dontFixup = true;
-            };
-            docker = import ./docker.nix {
-              inherit self pkgs flockenzeit;
-              website = packages.default;
-            };
-          };
+        {
+          inherit packages;
           devShells = {
+            ci = pkgs.mkShell {
+              packages = commonShellPackages;
+            };
             default = pkgs.mkShell {
+              inputsFrom = [ packages.builder ];
               packages = with pkgs; [
-                bun
-                flyctl
-              ] ++ nativeBuildInputs ++ (with pkgs.nodePackages; [
-                prettier
-              ]);
+                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
index 039c968..5b25a9d 100644
--- a/fly.toml
+++ b/fly.toml
@@ -7,23 +7,35 @@ 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 = 3000
+  internal_port = 80
   force_https = true
   auto_stop_machines = false
   auto_start_machines = true
   min_machines_running = 3
   processes = ["app"]
-[http_service.http_options.response.headers]
-  Strict-Transport-Security = "max-age=31536000; includeSubdomains; preload"
+  [http_service.concurrency]
+    type = "requests"
+    hard_limit = 20000
+    soft_limit = 15000
+[http_service.http_options.response]
+  pristine = true
 [[http_service.checks]]
-  grace_period = "10s"
+  grace_period = "15s"
   interval = "30s"
   method = "GET"
-  timeout = "5s"
+  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/package.json b/package.json
deleted file mode 100644
index 47922d2..0000000
--- a/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "name": "homestead",
-  "module": "src/index.ts",
-  "scripts": {
-    "start": "bun run .",
-    "build": "bun run bin/build.ts"
-  },
-  "devDependencies": {
-    "bun-types": "latest",
-    "cheerio": "1.0.0-rc.12",
-    "highlight.js": "^11.8.0",
-    "marked": "^9.0.3",
-    "marked-highlight": "^2.0.6",
-    "toml-matter": "^1.0.0"
-  },
-  "peerDependencies": {
-    "typescript": "^5.0.0"
-  },
-  "type": "module",
-  "dependencies": {
-    "@sentry/node": "^7.70.0",
-    "bun-prometheus-client": "^0.0.2",
-    "loglevel": "^1.8.1",
-    "toml": "^3.0.0"
-  }
-}
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/src/app.ts b/src/app.ts
deleted file mode 100644
index ac11c4d..0000000
--- a/src/app.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-import path from "node:path";
-import fs, { Stats } from "node:fs";
-import type { BunFile, Serve } from "bun";
-import * as Sentry from "@sentry/node";
-import prom from "bun-prometheus-client";
-import log from "loglevel";
-
-import config from "./config";
-
-log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc);
-
-Sentry.init({
-  release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`,
-  tracesSampleRate: 1.0,
-});
-
-const expectedHostURL = new URL(
-  Bun.env.NODE_ENV === "production" ? config.base_url : "http://localhost:3000",
-);
-const defaultHeaders = {
-  ...config.extra.headers,
-  vary: "Accept-Encoding",
-};
-
-type File = {
-  filename: string;
-  handle: BunFile;
-  relPath: string;
-  headers?: Record<string, string>;
-  type: string;
-  size: number;
-  mtime: Date;
-};
-
-const metrics = {
-  requests: new prom.Counter({
-    name: "homestead_requests",
-    help: "Number of requests by path, status code, and method",
-    labelNames: ["path", "status_code", "method", "content_encoding"],
-  }),
-  requestDuration: new prom.Histogram({
-    name: "homestead_request_duration_seconds",
-    help: "Request duration in seconds",
-    labelNames: ["path"],
-  }),
-};
-
-let files = new Map<string, File>();
-
-function registerFile(
-  path: string,
-  pathname: string,
-  filename: string,
-  stat: Stats,
-): void {
-  pathname = "/" + (pathname === "." || pathname === "./" ? "" : pathname);
-
-  if (files.get(pathname) !== undefined) {
-    console.warn("File already registered:", pathname);
-  }
-  const handle = Bun.file(filename);
-  files.set(pathname, {
-    filename,
-    relPath: "/" + path,
-    handle: handle,
-    type: pathname.startsWith("/feed-styles.xsl") ? "text/xsl" : handle.type,
-    headers:
-      pathname === "/404.html"
-        ? Object.assign({}, defaultHeaders, { "cache-control": "no-cache" })
-        : undefined,
-    size: stat.size,
-    mtime: stat.mtime,
-  });
-}
-
-function walkDirectory(root: string, dir: string) {
-  const absDir = path.join(root, dir);
-  for (let pathname of fs.readdirSync(absDir)) {
-    const relPath = path.join(dir, pathname);
-    const absPath = path.join(absDir, pathname);
-    const stat = fs.statSync(absPath);
-    if (stat.isDirectory()) {
-      walkDirectory(root, relPath + path.sep);
-    } else if (stat.isFile()) {
-      if (pathname.startsWith("index.html")) {
-        const dir = relPath.replace("index.html", "");
-        registerFile(relPath, dir, absPath, stat);
-        if (dir !== "") {
-          registerFile(relPath, dir + path.sep, absPath, stat);
-        }
-      } else {
-        registerFile(relPath, relPath, absPath, stat);
-      }
-    }
-  }
-}
-
-walkDirectory("public/", "");
-
-async function serveFile(
-  file: File,
-  statusCode: number = 200,
-  extraHeaders: Record<string, string> = {},
-): Promise<Response> {
-  return new Response(await file.handle.arrayBuffer(), {
-    headers: {
-      "last-modified": file.mtime.toUTCString(),
-      ...extraHeaders,
-      ...(file.headers || defaultHeaders),
-    },
-    status: statusCode,
-  });
-}
-
-function parseIfModifiedSinceHeader(header: string | null): number {
-  return header ? new Date(header).getTime() + 999 : 0;
-}
-
-export const metricsServer = {
-  port: 9091,
-  fetch: async function (request) {
-    const pathname = new URL(request.url).pathname;
-    switch (pathname) {
-      case "/metrics":
-        return new Response(await prom.register.metrics());
-      default:
-        return new Response("", { status: 404 });
-    }
-  },
-} satisfies Serve;
-
-export const server = {
-  fetch: async function (request) {
-    const url = new URL(request.url);
-    const pathname = url.pathname.replace(/\/\/+/g, "/");
-    const endTimer = metrics.requestDuration.startTimer({ path: pathname });
-    let status;
-    const transaction = Sentry.startTransaction({
-      name: pathname,
-      op: "http.server",
-      description: `${request.method} ${pathname}`,
-      tags: {
-        url: request.url,
-        "http.host": request.headers.get("host"),
-        "http.method": request.method,
-        "http.user_agent": request.headers.get("user-agent"),
-      },
-    });
-    try {
-      if (pathname === "/health") {
-        return new Response("OK", { status: (status = 200) });
-      } else if (
-        config.redirect_other_hostnames &&
-        request.headers.get("host") !== expectedHostURL.host
-      ) {
-        return new Response("", {
-          status: (status = 301),
-          headers: {
-            location: new URL(pathname, expectedHostURL).toString(),
-          },
-        });
-      }
-      const file = files.get(pathname);
-      let contentEncoding = "identity";
-      let suffix = "";
-      if (file && (await file.handle.exists())) {
-        if (
-          parseIfModifiedSinceHeader(
-            request.headers.get("if-modified-since"),
-          ) >= file?.mtime.getTime()
-        ) {
-          metrics.requests.inc({
-            method: request.method,
-            path: pathname,
-            status_code: (status = 304),
-          });
-          transaction.setHttpStatus(304);
-          return new Response("", { status: status, headers: defaultHeaders });
-        }
-        const encodings = (request.headers.get("accept-encoding") || "")
-          .split(",")
-          .map((x) => x.trim().toLowerCase());
-        if (encodings.includes("br") && files.has(pathname + ".br")) {
-          contentEncoding = "br";
-          suffix = ".br";
-        } else if (encodings.includes("zstd") && files.has(pathname + ".zst")) {
-          contentEncoding = "zstd";
-          suffix = ".zst";
-        } else if (encodings.includes("gzip") && files.has(pathname + ".gz")) {
-          contentEncoding = "gzip";
-          suffix = ".gz";
-        }
-
-        status = 200;
-        transaction.setHttpStatus(status);
-        metrics.requests.inc({
-          method: request.method,
-          path: pathname,
-          status_code: status,
-          content_encoding: contentEncoding,
-        });
-        const endFile = files.get(pathname + suffix);
-        if (!endFile) {
-          throw new Error(`File ${pathname} not found`);
-        }
-        return serveFile(endFile, status, {
-          "content-encoding": contentEncoding,
-          "content-type": file.type,
-        });
-      } else {
-        if (files.has(pathname + "/")) {
-          log.info(`Redirecting to: ${pathname + "/"}`);
-          metrics.requests.inc({
-            method: request.method,
-            path: pathname,
-            status_code: (status = 302),
-          });
-          return new Response("", {
-            status: status,
-            headers: { location: pathname + "/" },
-          });
-        }
-        metrics.requests.inc({
-          method: request.method,
-          path: pathname,
-          status_code: (status = 404),
-          content_encoding: "identity",
-        });
-        transaction.setHttpStatus(status);
-        const notfound = files.get("/404.html");
-        if (notfound) {
-          return serveFile(notfound, status, {
-            "content-type": "text/html; charset=utf-8",
-          });
-        } else {
-          log.warn("404.html not found");
-          return new Response("404 Not Found", {
-            status: status,
-            headers: { "content-type": "text/plain", ...defaultHeaders },
-          });
-        }
-      }
-    } catch (error) {
-      transaction.setHttpStatus((status = 503));
-      metrics.requests.inc({
-        method: request.method,
-        path: pathname,
-        status_code: status,
-        content_encoding: "identity",
-      });
-      Sentry.captureException(error);
-      console.error("Error", error);
-      return new Response("Something went wrong", { status: status });
-    } finally {
-      const seconds = endTimer();
-      metrics.requestDuration.observe(seconds);
-      transaction.finish();
-      console.log(request.method, status, pathname);
-    }
-  },
-} satisfies Serve;
diff --git a/src/config.ts b/src/config.ts
deleted file mode 100644
index a96b9c5..0000000
--- a/src/config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import path from "node:path";
-import fs from "node:fs";
-import toml from "toml";
-
-export default toml.parse(fs.readFileSync("config.toml", "utf-8"));
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index f0db416..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import log from "loglevel";
-
-import { server, metricsServer } from "./app";
-
-log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc);
-
-const metricsServed = Bun.serve(metricsServer);
-console.info(`Metrics server started on port ${metricsServed.port}`);
-
-const served = Bun.serve(server);
-console.info(`Serving website on http://${served.hostname}:${served.port}/`);
-
-process.on("SIGTERM", function () {
-  log.info("SIGTERM received, shutting down...");
-  metricsServed.stop();
-  served.stop();
-  process.exit(0);
-});
-
-process.on("SIGINT", function () {
-  log.info("SIGINT received, shutting down...");
-  metricsServed.stop();
-  served.stop();
-  process.exit(0);
-});
diff --git a/src/posts.ts b/src/posts.ts
deleted file mode 100644
index c727e12..0000000
--- a/src/posts.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import path from "node:path";
-import fs from "node:fs/promises";
-
-import { matter } from "toml-matter";
-import * as marked from "marked";
-
-type MatterFile = ReturnType<typeof matter>;
-
-export type Post = {
-  input: string;
-  output: string;
-  basename: string;
-  url: string;
-  title: string;
-  date: Date;
-  description: string | undefined;
-  taxonomies: Record<string, string[]>;
-};
-
-export async function getPost(filename: string): Promise<MatterFile> {
-  return matter(await Bun.file(filename).text());
-}
-
-export async function readPosts(
-  root: string,
-  inputDir: string,
-  outputDir: string,
-): Promise<{ posts: Array<Post>; tags: Set<string> }> {
-  let tags = new Set<string>();
-  let posts = new Array<Post>();
-  const subdir = path.join(root, inputDir);
-  for (let pathname of await fs.readdir(subdir)) {
-    const pathFromDir = path.join(inputDir, pathname);
-    const pathFromRoot = path.join(subdir, pathname);
-    const stat = await fs.stat(pathFromRoot);
-    if (stat.isFile() && path.extname(pathname) === ".md") {
-      if (pathname !== "_index.md") {
-        const input = pathFromRoot;
-        const output = pathFromRoot
-          .replace(root, outputDir)
-          .replace(".md", "/index.html");
-        const url = pathFromRoot.replace(root, "").replace(".md", "/");
-
-        const file = await getPost(input);
-
-        file.data.taxonomies?.tags?.map((t: string) =>
-          tags.add(t.toLowerCase()),
-        );
-        posts.push({
-          input,
-          output,
-          basename: path.basename(pathname, ".md"),
-          url,
-          ...file.data,
-        } as Post);
-      }
-    }
-  }
-  return {
-    posts: posts.sort((a, b) => b.date.getTime() - a.date.getTime()),
-    tags,
-  };
-}
diff --git a/src/templates.ts b/src/templates.ts
deleted file mode 100644
index ac58a2c..0000000
--- a/src/templates.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import * as fs from "node:fs/promises";
-import * as cheerio from "cheerio";
-import { matter } from "toml-matter";
-import { Marked } from "marked";
-import log from "loglevel";
-
-import config from "./config";
-import { getPost, readPosts, type Post } from "./posts";
-
-const marked = new Marked();
-marked.use({
-  gfm: true,
-});
-
-function addMenu(
-  parent: cheerio.Cheerio<cheerio.AnyNode>,
-  child: cheerio.Cheerio<cheerio.AnyNode>,
-) {
-  parent.empty();
-  for (const link of config.menus.main) {
-    parent.append(child.clone().attr("href", link.url).text(link.name));
-  }
-}
-
-export async function layout(
-  $: cheerio.CheerioAPI,
-  pageTitle: string,
-): Promise<cheerio.CheerioAPI> {
-  $("html").attr("lang", config.default_language);
-  $("head > link[rel=alternate]").attr("title", config.title);
-  addMenu($("nav"), $("nav a"));
-  $(".title").text(config.title);
-  $("title").text(pageTitle);
-  $(".p-name").text(pageTitle);
-  $("head")
-    .children("style")
-    .text(await Bun.file("templates/style.css").text());
-  return $;
-}
-
-async function render404Page(): Promise<string> {
-  const $ = await layout(
-    cheerio.load(await Bun.file("templates/404.html").text()),
-    "404 Not Found",
-  );
-  return $.html();
-}
-
-async function renderHomepage(posts: Array<Post>): Promise<string> {
-  const file = matter(await Bun.file("content/_index.md").text());
-  const $ = await layout(
-    cheerio.load(await Bun.file("templates/homepage.html").text()),
-    config.title,
-  );
-
-  $("body").addClass("h-card");
-  $(".title").addClass("p-name").addClass("u-url");
-  $("#content").html(await marked.parse(file.content));
-  const $feed = $(".h-feed");
-  const $entry = $(".h-entry").remove();
-
-  for (const post of posts) {
-    const $post = $entry.clone();
-    $post.find(".p-name").text(post.title);
-    $post.find(".u-url").attr("href", post.url);
-    $post
-      .find(".dt-published")
-      .attr("datetime", post.date.toISOString())
-      .text(post.date.toISOString().slice(0, 10));
-    $post.appendTo($feed);
-  }
-
-  $(".u-email").attr("href", `mailto:${config.email}`).text(config.email);
-  const $elsewhere = $(".elsewhere");
-  const $linkRelMe = $elsewhere.find(".u-url[rel=me]").parentsUntil("ul");
-  $linkRelMe.remove();
-  for (const link of config.menus.me) {
-    const $link = $linkRelMe.clone();
-    $link.find("a").attr("href", link.url).text(link.name);
-    $link.appendTo($elsewhere);
-  }
-  return $.html();
-}
-
-async function renderPost(file: Post, content: string) {
-  const $ = await layout(
-    cheerio.load(await Bun.file("templates/post.html").text()),
-    file.title,
-  );
-
-  $(".title").addClass("h-card p-author").attr("rel", "author");
-  $(".h-entry .dt-published")
-    .attr("datetime", file.date.toISOString())
-    .text(file.date.toISOString().slice(0, 10));
-  $(".h-entry .e-content").html(content);
-  const categories = $(".h-entry .p-categories");
-  const cat = categories.find(".p-category").parentsUntil(categories);
-  cat.remove();
-  for (const tag of file.taxonomies.tags) {
-    categories.append(
-      cat
-        .clone()
-        .find(".p-category")
-        .attr("href", `/tags/${tag}/`)
-        .text(`#${tag}`)
-        .parent(),
-    );
-  }
-
-  return $.html();
-}
-
-async function renderListPage(tag: string, posts: Post[]) {
-  const $ = await layout(
-    cheerio.load(await Bun.file("templates/list.html").text()),
-    tag || config.title,
-  );
-  const $feed = $(".h-feed");
-  const $tpl = $(".h-entry").remove();
-  $(".title").addClass("p-author h-card").attr("rel", "author");
-  if (tag === "") {
-    $(".filter").remove();
-  } else {
-    $(".filter").find("h3").text(`#${tag}`);
-  }
-
-  for (const post of posts) {
-    const $post = $tpl.clone();
-    $post.find(".p-name").text(post.title);
-    $post.find(".u-url").attr("href", post.url);
-    $post
-      .find(".dt-published")
-      .attr("datetime", post.date.toISOString())
-      .text(post.date.toISOString().slice(0, 10));
-    $post.appendTo($feed);
-  }
-  return $.html();
-}
-
-async function renderTags(tags: string[]) {
-  const $ = await layout(
-    cheerio.load(await Bun.file("templates/tags.html").text()),
-    config.title,
-  );
-  const $tags = $(".tags");
-  const $tpl = $(".h-feed");
-  $tpl.remove();
-  for (const tag of tags) {
-    const $tag = $tpl.clone();
-    $tag.find("a").attr("href", `/tags/${tag}/`).text(`#${tag}`);
-    $tag.appendTo($tags);
-  }
-  return $.html();
-}
-
-const makeTagURI = (specific: string) =>
-  `tag:${config.original_domain},${config.domain_start_date}:${specific}`;
-
-async function renderFeed(title: string, posts: Post[], tag?: string) {
-  const $ = cheerio.load(await Bun.file("templates/feed.xml").text(), {
-    xml: true,
-  });
-  const $feed = $("feed");
-  $feed.children("title").text(title);
-  $feed.children("link").attr("href", config.base_url);
-  $feed.children("id").text(makeTagURI(tag || "feed"));
-  $feed.children("updated").text(posts[0].date.toISOString());
-
-  const $tpl = $("feed > entry").remove();
-  for (const post of posts) {
-    const $post = $tpl.clone();
-    $post.children("title").text(post.title);
-    $post
-      .children("link")
-      .attr("href", new URL(post.url, config.base_url).href);
-    $post.children("id").text(makeTagURI(post.basename));
-    $post.children("updated").text(post.date.toISOString());
-    $post.find("author > name").text(config.title);
-    $post.children("summary").text(post.description || "");
-    const content = marked.parse((await getPost(post.input)).content);
-    $post.children("content").html(Bun.escapeHTML(await content));
-    $post.appendTo($feed);
-  }
-
-  return $.xml();
-}
-
-async function renderFeedStyles() {
-  const $ = cheerio.load(await Bun.file("templates/feed-styles.xsl").text(), {
-    xml: true,
-  });
-  $("style").text(await Bun.file("templates/style.css").text());
-  return $.xml();
-}
-
-export default async function generateSite() {
-  const tasks = [];
-  const { posts, tags } = await readPosts("content", "post", "public");
-  await fs.mkdir("public/post", { recursive: true });
-  for (const post of posts) {
-    const content = await marked.parse((await getPost(post.input)).content);
-    await fs.mkdir(`public/post/${post.basename}`, { recursive: true });
-    tasks.push(async () => {
-      log.debug(`Rendering post ${post.basename} to ${post.output}`);
-      return Bun.write(post.output, await renderPost(post, content));
-    });
-  }
-  await fs.mkdir("public/tags", { recursive: true });
-  tasks.push(async () => {
-    log.debug("Rendering tags page to public/tags/index.html");
-    return Bun.write("public/tags/index.html", await renderTags([...tags]));
-  });
-  for (const tag of tags) {
-    log.debug(`Processing tag ${tag}`);
-    const matchingPosts = posts.filter((p) => p.taxonomies.tags.includes(tag));
-    await fs.mkdir(`public/tags/${tag}`, { recursive: true });
-    tasks.push(async () => {
-      log.debug(`Rendering tag ${tag} to public/tags/${tag}/index.html`);
-      return Bun.write(
-        `public/tags/${tag}/index.html`,
-        await renderListPage(tag, matchingPosts),
-      );
-    });
-
-    tasks.push(async () => {
-      log.debug(`Rendering tag ${tag} feed to public/tags/${tag}/atom.xml`);
-      return Bun.write(
-        `public/tags/${tag}/atom.xml`,
-        await renderFeed(`${config.title} - ${tag}`, matchingPosts),
-      );
-    });
-  }
-  tasks.push(async () => {
-    log.debug("Rendering posts page to public/post/index.html");
-    return Bun.write("public/post/index.html", await renderListPage("", posts));
-  });
-  tasks.push(async () => {
-    log.debug("Rendering site feed to public/atom.xml");
-    return Bun.write("public/atom.xml", await renderFeed(config.title, posts));
-  });
-  tasks.push(async () => {
-    log.debug("Rendering feed styles to public/feed-styles.xsl");
-    return Bun.write("public/feed-styles.xsl", await renderFeedStyles());
-  });
-  tasks.push(async () => {
-    log.debug("Rendering homepage to public/index.html");
-    return Bun.write(
-      "public/index.html",
-      await renderHomepage(posts.slice(0, 3)),
-    );
-  });
-  tasks.push(async () => {
-    log.debug("Rendering 404 page to public/404.html");
-    return Bun.write("public/404.html", await render404Page());
-  });
-  for (const task of tasks) {
-    await task();
-  }
-}
diff --git a/templates/404.html b/templates/404.html
index 4e64fcc..eade0f9 100644
--- a/templates/404.html
+++ b/templates/404.html
@@ -28,10 +28,11 @@
       <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
     </main>
     <footer>
-      Licensed under a
+      Content is
       <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >Creative Commons Attribution 4.0 International License</a
-      >.
+        >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/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/homepage.html b/templates/homepage.html
index 5004340..d256e8c 100644
--- a/templates/homepage.html
+++ b/templates/homepage.html
@@ -11,6 +11,7 @@
       title=""
       href="/atom.xml"
     />
+    <link href="" rel="canonical" />
     <style></style>
   </head>
   <body>
@@ -30,11 +31,7 @@
         <ul class="h-feed">
           <li class="h-entry">
             <span>
-              <time
-                class="dt-published"
-                datetime="2000-12-31T12:33:02+02:00"
-                pubdate
-              >
+              <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
                 2000-12-31
               </time>
             </span>
@@ -47,19 +44,21 @@
         <ul class="elsewhere">
           <li>
             <a class="u-email" rel="me" href="mailto:user@example.com"
-            >user@example.com</a></li>
+              >user@example.com</a
+            >
+          </li>
           <li>
             <a class="u-url" rel="me" href="http://example.com">Example</a>
           </li>
-          </li>
         </ul>
       </section>
     </main>
     <footer>
-      Licensed under a
+      Content is
       <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >Creative Commons Attribution 4.0 International License</a
-      >.
+        >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
index ffe29f0..74d6576 100644
--- a/templates/list.html
+++ b/templates/list.html
@@ -11,6 +11,7 @@
       title="Site Title"
       href="/atom.xml"
     />
+    <link href="" rel="canonical" />
     <style></style>
   </head>
   <body>
@@ -25,7 +26,7 @@
     </header>
     <main id="content">
       <div class="filter">
-        <h3>Tag</h3>
+        <h3 class="filter">Tag</h3>
         <small>
           <a href="../">Remove filter</a>
         </small>
@@ -33,11 +34,7 @@
       <ul class="h-feed">
         <li class="h-entry">
           <span>
-            <time
-              class="dt-published"
-              datetime="2000-12-31T12:33:02+02:00"
-              pubdate
-            >
+            <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
               2000-12-31
             </time>
           </span>
@@ -46,10 +43,11 @@
       </ul>
     </main>
     <footer>
-      Licensed under a
+      Content is
       <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >Creative Commons Attribution 4.0 International License</a
-      >.
+        >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
index d08ea86..7574a1f 100644
--- a/templates/post.html
+++ b/templates/post.html
@@ -11,6 +11,7 @@
       title=""
       href="/atom.xml"
     />
+    <link href="" rel="canonical" />
     <style></style>
   </head>
   <body>
@@ -27,24 +28,52 @@
       <article class="h-entry">
         <h1 class="p-name">Post Title</h1>
         <p>
-          <time class="dt-published" pubdate>2000-12-31</time>
+          <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>
-        <ul class="p-categories tags">
+        <div class="tags">
           Tags:
-          <li><a class="p-category" href="/tags/sample/">#sample</a></li>
-        </ul>
+          <ul class="p-categories tags">
+            <li><a class="p-category" href="/tags/sample/">#sample</a></li>
+          </ul>
+        </div>
       </article>
     </main>
     <footer>
-      Licensed under a
+      Content is
       <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >Creative Commons Attribution 4.0 International License</a
-      >.
+        >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
index 54ad0da..8d21237 100644
--- a/templates/style.css
+++ b/templates/style.css
@@ -41,6 +41,10 @@ a {
   border: 0;
 }
 
+.filter {
+  margin-bottom: 0;
+}
+
 time {
   font-style: italic;
 }
@@ -54,7 +58,11 @@ nav a {
   font-size: small;
 }
 
-.tags > li {
+.tags ul {
+  display: inline-block;
+}
+
+.tags li {
   list-style: none;
   display: inline-block;
   padding-right: 1ex;
diff --git a/templates/tags.html b/templates/tags.html
index 6a58055..79c1c09 100644
--- a/templates/tags.html
+++ b/templates/tags.html
@@ -11,6 +11,7 @@
       title="Site title"
       href="/atom.xml"
     />
+    <link href="" rel="canonical" />
     <style></style>
   </head>
   <body>
@@ -24,7 +25,7 @@
       </nav>
     </header>
     <main id="content">
-      <h3 style="margin-bottom: 0">Tags</h3>
+      <h3 class="filter">Tags</h3>
       <ul class="tags">
         <li class="h-feed">
           <a href="/tags/tag">#tag</a>
@@ -32,10 +33,11 @@
       </ul>
     </main>
     <footer>
-      Licensed under a
+      Content is
       <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
-        >Creative Commons Attribution 4.0 International License</a
-      >.
+        >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/test/index.test.ts b/test/index.test.ts
deleted file mode 100644
index 6e29e3d..0000000
--- a/test/index.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { type Server } from "bun";
-import { expect, test, beforeAll, afterAll } from "bun:test";
-
-import { server as app } from "../src/app";
-
-const port = 33000;
-const base = `http://localhost:${port}/`;
-let server: Server;
-
-beforeAll(async function () {
-  server = Bun.serve(Object.assign({}, app, { port }));
-});
-
-afterAll(function () {
-  server.stop();
-});
-
-test("/ returns 200", async function () {
-  const res = await fetch(base);
-  expect(res.status).toBe(200);
-});
-
-test("/asdf returns 404", async function () {
-  const res = await fetch(`${base}asdf`);
-  expect(res.status).toBe(404);
-});
-
-test("/ returns 304 with newer if-modified-since header", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "if-modified-since": new Date().toUTCString(),
-    },
-  });
-  expect(res.status).toBe(304);
-  expect(res.headers.get("vary")).toBe("Accept-Encoding");
-});
-
-test("/ returns 200 with older if-modified-since header", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "if-modified-since": new Date(0).toUTCString(),
-    },
-  });
-  expect(res.status).toBe(200);
-});
-
-test("/ returns gzipped content with accept-encoding: gzip", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "accept-encoding": "gzip",
-    },
-  });
-  expect(res.status).toBe(200);
-  // Bun 0.8.1 this doesn't work, but `verbose` shows it's there
-  // expect(res.headers.get("content-encoding")).toBe("gzip");
-  // response is automatically gunzipped
-  const body = await res.text();
-  expect(body.length).toBeGreaterThan(
-    Number(res.headers.get("content-length")),
-  );
-});
-
-test("/ returns uncompressed content with accept-encoding: identity", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "accept-encoding": "identity",
-    },
-  });
-  expect(res.status).toBe(200);
-  const body = await res.text();
-  expect(body.length).toBe(Number(res.headers.get("content-length")));
-});
-
-test("/ returns brotli-compressed content with accept-encoding: br", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "accept-encoding": "br",
-    },
-  });
-  expect(res.status).toBe(200);
-  expect(res.headers.get("content-encoding")).toBe("br");
-  const body = await res.text();
-  expect(body.length).toBeLessThan(Number(res.headers.get("content-length")));
-});
-
-test("/ returns zstd-compressed content with accept-encoding: zstd", async function () {
-  const res = await fetch(base, {
-    headers: {
-      "accept-encoding": "zstd",
-    },
-  });
-  expect(res.status).toBe(200);
-  expect(res.headers.get("content-encoding")).toBe("zstd");
-  expect(res.headers.get("vary")).toBe("Accept-Encoding");
-  const body = await res.text();
-  expect(body.length).toBeLessThan(Number(res.headers.get("content-length")));
-});
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 79f7630..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-  "compilerOptions": {
-    "lib": ["ES2017", "ES2019", "ESNext"],
-    "module": "esnext",
-    "target": "esnext",
-    "moduleResolution": "bundler",
-    "moduleDetection": "force",
-    "allowImportingTsExtensions": true,
-    "noEmit": true,
-    "composite": true,
-    "strict": true,
-    "downlevelIteration": true,
-    "skipLibCheck": true,
-    "jsx": "preserve",
-    "allowSyntheticDefaultImports": true,
-    "forceConsistentCasingInFileNames": true,
-    "allowJs": true,
-    "paths": {
-      "toml-matter": ["./node_modules/toml-matter"]
-    },
-    "types": [
-      "bun-types" // add Bun global
-    ]
-  }
-}