about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.envrc8
-rw-r--r--.gitignore195
-rw-r--r--cmd/build/build.go598
-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--cmd/server/server.go187
-rw-r--r--config.toml33
-rw-r--r--content/LICENSE76
-rw-r--r--flake.lock6
-rw-r--r--flake.nix2
-rw-r--r--fly.toml6
-rw-r--r--gitlab-ci.yml47
-rw-r--r--go.mod40
-rw-r--r--go.sum265
-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.go29
-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-xjustfile39
-rw-r--r--nix/default.nix19
-rw-r--r--nix/gomod2nix.toml116
-rw-r--r--shell.nix3
-rw-r--r--templates/404.html7
-rw-r--r--templates/count.html6
-rw-r--r--templates/dev.html8
-rw-r--r--templates/homepage.html8
-rw-r--r--templates/list.html8
-rw-r--r--templates/post.html8
-rw-r--r--templates/tags.html8
36 files changed, 2091 insertions, 1254 deletions
diff --git a/.envrc b/.envrc
index 5973c50..8075d5b 100644
--- a/.envrc
+++ b/.envrc
@@ -1,2 +1,6 @@
-watch_file nix/*
-use flake
+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 bb1ec91..2932e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,178 +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/
-/website
-/.compressstamp
-/.formatstamp
-/server
-/result
-
-# Local Netlify folder
-.netlify
+# ...even if they are in subdirectories
+!*/
diff --git a/cmd/build/build.go b/cmd/build/build.go
deleted file mode 100644
index f165361..0000000
--- a/cmd/build/build.go
+++ /dev/null
@@ -1,598 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/xml"
-	"fmt"
-	"io"
-	"io/fs"
-	"log"
-	"log/slog"
-	"net/url"
-	"os"
-	"path"
-	"path/filepath"
-	"slices"
-	"strings"
-	"time"
-
-	"website/internal/atom"
-	"website/internal/config"
-
-	"github.com/BurntSushi/toml"
-	"github.com/PuerkitoBio/goquery"
-	"github.com/a-h/htmlformat"
-	"github.com/adrg/frontmatter"
-	"github.com/antchfx/xmlquery"
-	"github.com/antchfx/xpath"
-	"github.com/ardanlabs/conf/v3"
-	mapset "github.com/deckarep/golang-set/v2"
-	cp "github.com/otiai10/copy"
-	"github.com/pkg/errors"
-	"github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/extension"
-	htmlrenderer "github.com/yuin/goldmark/renderer/html"
-	"golang.org/x/net/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]
-
-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 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", "/")
-	markdown := goldmark.New(
-		goldmark.WithRendererOptions(
-			htmlrenderer.WithUnsafe(),
-		),
-		goldmark.WithExtensions(
-			extension.GFM,
-			extension.Footnote,
-			extension.Typographer,
-		),
-	)
-	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))
-			}
-
-			var buf bytes.Buffer
-			slog.Debug("rendering markdown in post", "post", pathFromRoot)
-			if err := markdown.Convert(*content, &buf); err != nil {
-				return nil, nil, errors.WithMessage(err, "could not convert markdown content")
-			}
-			post := Post{
-				Input:      pathFromRoot,
-				Output:     output,
-				Basename:   filepath.Base(url),
-				URL:        url,
-				PostMatter: *matter,
-				Content:    buf.String(),
-			}
-
-			posts = append(posts, post)
-		}
-	}
-	slices.SortFunc(posts, func(a, b Post) int {
-		return b.Date.Compare(a.Date)
-	})
-	return posts, tags, nil
-}
-
-func layout(filename string, config config.Config, pageTitle string) (*goquery.Document, error) {
-	html, err := os.Open(filename)
-	if err != nil {
-		return nil, err
-	}
-	defer html.Close()
-	css, err := os.ReadFile("templates/style.css")
-	if err != nil {
-		return nil, err
-	}
-	doc, err := goquery.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(".title").SetText(config.Title)
-	doc.Find("title").Add(".p-name").SetText(pageTitle)
-	doc.Find("head > style").SetHtml("\n" + string(css))
-	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, 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
-}
-
-func renderPost(post Post, config config.Config) (r io.Reader, err error) {
-	doc, err := layout("templates/post.html", config, post.PostMatter.Title)
-	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) (io.Reader, error) {
-	doc, err := layout("templates/tags.html", config, config.Title)
-	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) (io.Reader, error) {
-	var title string
-	if len(tag) > 0 {
-		title = tag
-	} else {
-		title = config.Title
-	}
-	doc, err := layout("templates/list.html", config, title)
-	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) (io.Reader, error) {
-	_, index, err := getPost("content/_index.md")
-	if err != nil {
-		return nil, err
-	}
-	doc, err := layout("templates/homepage.html", config, config.Title)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("body").AddClass("h-card")
-	doc.Find(".title").AddClass("p-name u-url")
-	var buf bytes.Buffer
-
-	md := goldmark.New(goldmark.WithRendererOptions(htmlrenderer.WithUnsafe()))
-	if err := md.Convert(*index, &buf); err != nil {
-		return nil, err
-	}
-	doc.Find("#content").SetHtml(buf.String())
-
-	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) (io.Reader, error) {
-	doc, err := layout("templates/404.html", config, "404 Not Found")
-	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 := os.Open("templates/feed.xml")
-	if err != nil {
-		return nil, err
-	}
-	defer reader.Close()
-	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 := os.Open("templates/feed-styles.xsl")
-	if err != nil {
-		return nil, err
-	}
-	defer reader.Close()
-	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)
-	css, err := os.ReadFile("templates/style.css")
-	if err != nil {
-		return nil, err
-	}
-	xmlquery.AddChild(style, &xmlquery.Node{
-		Type: xmlquery.TextNode,
-		Data: string(css),
-	})
-	return strings.NewReader(doc.OutputXML(true)), nil
-}
-
-func mkdirp(dirs ...string) error {
-	return os.MkdirAll(path.Join(dirs...), 0755)
-}
-
-func outputToFile(output io.Reader, filename ...string) error {
-	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 {
-	privateDir := path.Join(outDir, "private")
-	if err := mkdirp(privateDir); err != nil {
-		return errors.Errorf("could not create private directory: %w", err)
-	}
-	publicDir := path.Join(outDir, "public")
-	if err := mkdirp(publicDir); err != nil {
-		return errors.Errorf("could not create public directory: %w", err)
-	}
-
-	err := cp.Copy("static", publicDir, cp.Options{
-		PreserveTimes:     true,
-		PermissionControl: cp.AddPermission(0755),
-	})
-	if err != nil {
-		log.Panic(errors.Errorf("could not copy static files: %v", err))
-	}
-
-	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)
-	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)
-		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)
-	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)
-	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
-}
-
-type IOConfig struct {
-	Source      string `conf:"default:.,short:s"`
-	Destination string `conf:"default:website,short:d"`
-	BaseURL     config.URL
-}
-
-func main() {
-	if os.Getenv("DEBUG") != "" {
-		slog.SetLogLoggerLevel(slog.LevelDebug)
-	}
-	slog.Debug("starting build process")
-
-	ioConfig := 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")
-		}
-	}
-
-	config, err := config.GetConfig()
-	if err != nil {
-		log.Panic(errors.Errorf("could not get config: %v", err))
-	}
-
-	if ioConfig.BaseURL.URL != nil {
-		config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String())
-		if err != nil {
-			log.Panic(errors.Errorf("highly unlikely: %v", err))
-		}
-	}
-
-	err = os.RemoveAll(ioConfig.Destination)
-	if err != nil {
-		log.Panic(errors.Errorf("could not remove public directory: %v", err))
-	}
-
-	if err := build(ioConfig.Destination, *config); 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)
-	}
-
-	slog.Debug("done")
-}
diff --git a/cmd/build/main.go b/cmd/build/main.go
new file mode 100644
index 0000000..069f9bd
--- /dev/null
+++ b/cmd/build/main.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+	"fmt"
+	"io/fs"
+	"log"
+	"log/slog"
+	"os"
+
+	"website/internal/builder"
+
+	"github.com/BurntSushi/toml"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("build: ")
+	slog.Debug("starting build process")
+
+	ioConfig := builder.IOConfig{}
+	if help, err := conf.Parse("", &ioConfig); err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing I/O configuration: %v", err)
+	}
+
+	if ioConfig.Source != "." {
+		err := os.Chdir(ioConfig.Source)
+		if err != nil {
+			log.Panic("could not change to source directory")
+		}
+	}
+
+	if err := builder.BuildSite(ioConfig); err != nil {
+		switch cause := errors.Cause(err).(type) {
+		case *fs.PathError:
+			slog.Info("pathError")
+			slog.Error(fmt.Sprintf("%s", err))
+		case toml.ParseError:
+			slog.Info("parseError")
+			slog.Error(fmt.Sprintf("%s", err))
+		default:
+			slog.Info("other")
+			slog.Error(fmt.Sprintf("cause:%+v", errors.Cause(cause)))
+			slog.Error(fmt.Sprintf("%+v", cause))
+		}
+		os.Exit(1)
+	}
+}
diff --git a/cmd/cspgenerator/cspgenerator.go b/cmd/cspgenerator/cspgenerator.go
new file mode 100644
index 0000000..f79a591
--- /dev/null
+++ b/cmd/cspgenerator/cspgenerator.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"log"
+	"website/internal/config"
+)
+
+func main() {
+	err := config.GenerateCSP()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/cmd/dev/main.go b/cmd/dev/main.go
new file mode 100644
index 0000000..1a6ccea
--- /dev/null
+++ b/cmd/dev/main.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"io/fs"
+	"log"
+	"log/slog"
+	"net/http"
+	"net/http/httputil"
+
+	"os"
+	"os/exec"
+	"os/signal"
+	"path"
+	"path/filepath"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"website/internal/config"
+
+	"github.com/antage/eventsource"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/gohugoio/hugo/watcher"
+
+	"github.com/pkg/errors"
+)
+
+type DevConfig struct {
+	Source    string     `conf:"default:.,short:s"`
+	TempDir   string     `conf:"required,short:t"`
+	BaseURL   config.URL `conf:"default:http://localhost:3000"`
+	ServerURL config.URL `conf:"default:http://localhost:3001"`
+}
+
+func RunCommandPiped(ctx context.Context, command string, args ...string) (cmd *exec.Cmd, err error) {
+	slog.Debug(fmt.Sprintf("running command %s %s", command, strings.Join(args, " ")))
+	cmd = exec.CommandContext(ctx, command, args...)
+	cmd.Env = append(os.Environ(), "DEBUG=")
+	cmd.Cancel = func() error {
+		slog.Debug("signalling child")
+		err := cmd.Process.Signal(os.Interrupt)
+		if err != nil {
+			slog.Error(fmt.Sprintf("signal error: %v", err))
+		}
+		return err
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return
+	}
+
+	go io.Copy(os.Stdout, stdout)
+	go io.Copy(os.Stderr, stderr)
+
+	return
+}
+
+type FileWatcher struct {
+	*watcher.Batcher
+}
+
+func NewFileWatcher(pollTime time.Duration) (*FileWatcher, error) {
+	batcher, err := watcher.New(pollTime/5, pollTime, true)
+	if err != nil {
+		return nil, err
+	}
+	return &FileWatcher{batcher}, nil
+}
+
+func (watcher FileWatcher) WatchAllFiles(from string) error {
+	slog.Debug(fmt.Sprintf("watching files under %s", from))
+	err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		// slog.Debug(fmt.Sprintf("adding file %s to watcher", path))
+		if err = watcher.Add(path); err != nil {
+			return err
+		}
+		return nil
+	})
+	return err
+}
+
+func build(ctx context.Context, config DevConfig) error {
+	buildExe := filepath.Join(config.TempDir, "build")
+	cmd, err := RunCommandPiped(ctx, buildExe,
+		"--dest", path.Join(config.TempDir, "output"),
+		"--dev",
+	)
+	// cmd, err := RunCommandPiped(ctx, "./devfakebuild")
+
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+
+	err = cmd.Run()
+	slog.Debug(fmt.Sprintf("build command exited with code %d", cmd.ProcessState.ExitCode()))
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+	return nil
+}
+
+func server(ctx context.Context, devConfig DevConfig) error {
+	serverExe := path.Join(devConfig.TempDir, "server")
+
+	cmd, err := RunCommandPiped(ctx,
+		serverExe,
+		"--port", devConfig.ServerURL.Port(),
+		"--root", path.Join(devConfig.TempDir, "output"),
+		"--in-dev-server",
+	)
+	if err != nil {
+		return errors.WithMessage(err, "error running server command")
+	}
+	// cmd.Env = append(cmd.Env, "DEBUG=1")
+
+	cmdErr := make(chan error, 1)
+	done := make(chan struct{})
+	err = cmd.Start()
+	if err != nil {
+		return errors.WithMessage(err, fmt.Sprintf("error starting server binary"))
+	}
+
+	go func() {
+		err := cmd.Wait()
+		if err == nil && cmd.ProcessState.Exited() {
+			err = errors.Errorf("server exited unexpectedly")
+		}
+
+		cmdErr <- err
+		close(done)
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			slog.Debug("server context done")
+			cmd.Process.Signal(os.Interrupt)
+			<-done
+		case err := <-cmdErr:
+			return err
+		}
+	}
+}
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	var wg sync.WaitGroup
+
+	devConfig := DevConfig{}
+	help, err := conf.Parse("", &devConfig)
+	if err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing dev configuration: %v", err)
+	}
+
+	slog.Debug(fmt.Sprintf("using folder %s for build output", devConfig.TempDir))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	slog.Debug("setting interrupt handler")
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		sig := <-c
+		slog.Info(fmt.Sprintf("shutting down on signal %d", sig))
+		cancel()
+		sig = <-c
+		slog.Info(fmt.Sprintf("got second signal, dying %d", sig))
+		os.Exit(1)
+	}()
+
+	serverChan := make(chan bool, 1)
+	eventsource := eventsource.New(nil, nil)
+	defer eventsource.Close()
+	srv := http.Server{
+		Addr: devConfig.BaseURL.Host,
+	}
+	devCtx, devCancel := context.WithTimeout(ctx, 1*time.Second)
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer devCancel()
+		slog.Debug("waiting for first server launch")
+		<-serverChan
+		slog.Debug("got first server launch event")
+
+		http.Handle("/", &httputil.ReverseProxy{
+			Rewrite: func(req *httputil.ProxyRequest) {
+				req.SetURL(devConfig.ServerURL.URL)
+				req.Out.Host = req.In.Host
+			},
+		})
+		http.Handle("/_/reload", eventsource)
+		done := make(chan bool)
+		go func() {
+			err := srv.ListenAndServe()
+			if err != nil && err != http.ErrServerClosed {
+				slog.Error(err.Error())
+				cancel()
+			}
+			done <- true
+		}()
+		go func() {
+			for {
+				select {
+				case ready := <-serverChan:
+					if ready {
+						slog.Debug("sending reload message")
+						eventsource.SendEventMessage("reload", "", "")
+					} else {
+						slog.Debug("server not ready")
+					}
+				}
+			}
+		}()
+		slog.Info(fmt.Sprintf("dev server listening on %s", devConfig.BaseURL.Host))
+		<-done
+		slog.Debug("dev server closed")
+	}()
+
+	fw, err := NewFileWatcher(500 * time.Millisecond)
+	if err != nil {
+		log.Fatalf("error creating file watcher: %v", err)
+	}
+	err = fw.WatchAllFiles("content")
+	if err != nil {
+		log.Fatalf("could not watch files in content directory: %v", err)
+	}
+	err = fw.WatchAllFiles("templates")
+	if err != nil {
+		log.Fatalf("could not watch files in templates directory: %v", err)
+	}
+
+	var exitCode int
+	serverErr := make(chan error, 1)
+loop:
+	for {
+		serverCtx, stopServer := context.WithCancel(ctx)
+		slog.Debug("starting build")
+
+		err := build(ctx, devConfig)
+		if err != nil {
+			slog.Error(fmt.Sprintf("build error: %v", err))
+			// don't set up the server until there's a FS change event
+		} else {
+			slog.Debug("setting up server")
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				serverChan <- true
+				serverErr <- server(serverCtx, devConfig)
+			}()
+		}
+
+		select {
+		case <-ctx.Done():
+			slog.Debug("main context cancelled")
+			slog.Debug("calling server shutdown")
+			srv.Shutdown(devCtx)
+			exitCode = 1
+			break loop
+		case event := <-fw.Events:
+			slog.Debug(fmt.Sprintf("event received: %v", event))
+			stopServer()
+			serverChan <- false
+			slog.Debug("waiting for server shutdown")
+			<-serverErr
+			slog.Debug("server shutdown completed")
+			continue
+		case err = <-serverErr:
+			if err != nil && err != context.Canceled {
+				var exerr *exec.ExitError
+				slog.Error(fmt.Sprintf("server reported error: %v", err))
+				if errors.As(err, &exerr) {
+					slog.Debug("server exit error")
+					exitCode = exerr.ExitCode()
+				} else {
+					slog.Debug("server other error")
+					exitCode = 1
+				}
+				break
+			}
+			slog.Debug("no error or server context cancelled")
+			continue
+		}
+
+		slog.Debug("waiting on server")
+		exitCode = 0
+		break
+	}
+
+	slog.Debug("waiting for wg before shutting down")
+	wg.Wait()
+	os.Exit(exitCode)
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..bae215a
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"log/slog"
+	"os"
+	"os/signal"
+	"sync"
+
+	"website/internal/server"
+
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+var (
+	CommitSHA string
+	ShortSHA  string
+)
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("server: ")
+
+	runtimeConfig := server.Config{}
+	help, err := conf.Parse("", &runtimeConfig)
+	if err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing runtime configuration: %v", err)
+	}
+
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt)
+	sv, err := server.New(&runtimeConfig)
+	if err != nil {
+		log.Fatalf("error setting up server: %v", err)
+	}
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sig := <-c
+		log.Printf("signal captured: %v", sig)
+		<-sv.Stop()
+		slog.Debug("server stopped")
+	}()
+
+	sErr := make(chan error)
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sErr <- sv.Start()
+	}()
+	if !runtimeConfig.InDevServer {
+		log.Printf("server listening on %s", sv.Addr)
+	}
+
+	err = <-sErr
+	if err != nil {
+		// Error starting or closing listener:
+		log.Fatalf("error: %v", err)
+	}
+	wg.Wait()
+}
diff --git a/cmd/server/server.go b/cmd/server/server.go
deleted file mode 100644
index cc1821a..0000000
--- a/cmd/server/server.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package main
-
-import (
-	"errors"
-	"fmt"
-	"io"
-	"log"
-	"net"
-	"net/http"
-	"os"
-	"time"
-
-	cfg "website/internal/config"
-
-	"github.com/ansrivas/fiberprometheus/v2"
-	"github.com/ardanlabs/conf/v3"
-	"github.com/getsentry/sentry-go"
-	"github.com/gofiber/contrib/fibersentry"
-	"github.com/gofiber/fiber/v2"
-	"github.com/gofiber/fiber/v2/middleware/cache"
-	"github.com/gofiber/fiber/v2/middleware/compress"
-	"github.com/gofiber/fiber/v2/middleware/etag"
-	"github.com/gofiber/fiber/v2/middleware/filesystem"
-	"github.com/gofiber/fiber/v2/middleware/healthcheck"
-	"github.com/gofiber/fiber/v2/middleware/logger"
-	"github.com/gofiber/fiber/v2/middleware/recover"
-	"github.com/gofiber/fiber/v2/middleware/skip"
-
-	"github.com/shengyanli1982/law"
-)
-
-type Config struct {
-	Production             bool    `conf:"default:false"`
-	ListenAddress          string  `conf:"default:localhost"`
-	Port                   uint16  `conf:"default:3000,short:p"`
-	BaseURL                cfg.URL `conf:"default:http://localhost:3000,short:b"`
-	RedirectOtherHostnames bool    `conf:"default:false"`
-}
-
-// TODO purge CSS
-// TODO HTTP2 https://github.com/dgrr/http2
-
-type Host struct {
-	Fiber *fiber.App
-}
-
-var Commit string
-
-func main() {
-	runtimeConfig := Config{}
-	if help, err := conf.Parse("", &runtimeConfig); err != nil {
-		if errors.Is(err, conf.ErrHelpWanted) {
-			fmt.Println(help)
-			os.Exit(1)
-		}
-		log.Panicf("parsing runtime configuration: %v", err)
-	}
-	config, err := cfg.GetConfig()
-	if err != nil {
-		log.Panicf("parsing configuration file: %v", err)
-	}
-
-	err = sentry.Init(sentry.ClientOptions{
-		Dsn:         os.Getenv("SENTRY_DSN"),
-		Release:     os.Getenv("FLY_MACHINE_VERSION"),
-		Environment: os.Getenv("ENV"),
-	})
-	if err != nil {
-		log.Panic("could not set up sentry")
-	}
-	defer sentry.Flush(2 * time.Second)
-
-	metricServer := fiber.New(fiber.Config{
-		GETOnly:                  true,
-		StrictRouting:            true,
-		DisableDefaultDate:       true,
-		DisableHeaderNormalizing: true,
-		DisableStartupMessage:    true,
-		Network:                  fiber.NetworkTCP,
-	})
-	prometheus := fiberprometheus.New("homestead")
-	prometheus.RegisterAt(metricServer, "/metrics")
-
-	hosts := map[string]*Host{}
-
-	internal := fiber.New(fiber.Config{
-		GETOnly:       true,
-		StrictRouting: true,
-	})
-	internal.Use(healthcheck.New(healthcheck.Config{}))
-	hosts["fly-internal"] = &Host{internal}
-
-	website := fiber.New(fiber.Config{
-		EnableTrustedProxyCheck: true,
-		TrustedProxies:          []string{"172.16.0.0/16"},
-		ProxyHeader:             "Fly-Client-IP",
-		GETOnly:                 true,
-		ReadTimeout:             5 * time.Minute,
-		WriteTimeout:            5 * time.Minute,
-		StrictRouting:           true,
-		UnescapePath:            true,
-	})
-
-	website.Use(prometheus.Middleware)
-	website.Use(fibersentry.New(fibersentry.Config{}))
-	website.Use(func(c *fiber.Ctx) error {
-		for k, v := range config.Extra.Headers {
-			c.Set(k, v)
-		}
-		if c.Secure() {
-			c.Set("Strict-Transport-Security", "max-age=31536000; includeSubdomains; preload")
-		}
-		return c.Next()
-	})
-
-	website.Use(compress.New())
-	website.Use(cache.New(cache.Config{
-		CacheControl:         true,
-		Expiration:           24 * time.Hour,
-		StoreResponseHeaders: true,
-	}))
-	// must be after compress to be encoding-independent
-	website.Use(etag.New(etag.Config{
-		Weak: true,
-	}))
-
-	website.Use(recover.New(recover.Config{}))
-
-	prefix := "website/public"
-	publicFiles := http.Dir(prefix)
-	website.Use("/", filesystem.New(filesystem.Config{
-		Root:               publicFiles,
-		ContentTypeCharset: "utf-8",
-		MaxAge:             int((24 * time.Hour).Seconds()),
-	}))
-	website.Use(func(c *fiber.Ctx) error {
-		c.Status(fiber.StatusNotFound).Type("html", "utf-8")
-		content, err := os.Open("website/private/404.html")
-		if err != nil {
-			c.Type("txt")
-			return c.SendString("404 Not Found")
-		}
-		return c.SendStream(content)
-	})
-	hosts[runtimeConfig.BaseURL.Host] = &Host{website}
-
-	toplevel := fiber.New(fiber.Config{
-		DisableStartupMessage: runtimeConfig.Production,
-		ServerHeader:          fmt.Sprintf("website (%s)", Commit),
-		Network:               fiber.NetworkTCP,
-	})
-	toplevel.Get("/health", func(c *fiber.Ctx) error {
-		return c.SendStatus(fiber.StatusOK)
-	})
-	var logWriter io.Writer
-	if runtimeConfig.Production {
-		logWriter = law.NewWriteAsyncer(os.Stdout, nil)
-	} else {
-		logWriter = os.Stdout
-	}
-	toplevel.Use(skip.New(logger.New(logger.Config{
-		Output: logWriter,
-		Format: "${protocol} ${method} ${status} ${host} ${url} ${respHeader:Location}\n",
-	}), func(c *fiber.Ctx) bool {
-		return c.Hostname() == "fly-internal"
-	}))
-	toplevel.Use(func(c *fiber.Ctx) error {
-		host := hosts[c.Hostname()]
-		if host == nil {
-			if runtimeConfig.RedirectOtherHostnames {
-				return c.Redirect(runtimeConfig.BaseURL.JoinPath(c.OriginalURL()).String())
-			} else {
-				hosts[runtimeConfig.BaseURL.Host].Fiber.Handler()(c.Context())
-				return nil
-			}
-		} else {
-			host.Fiber.Handler()(c.Context())
-			return nil
-		}
-	})
-
-	go func() {
-		err := metricServer.Listen(net.JoinHostPort(runtimeConfig.ListenAddress, "9091"))
-		log.Printf("failed to start metrics server: %v", err)
-	}()
-	log.Fatal(toplevel.Listen(net.JoinHostPort(runtimeConfig.ListenAddress, fmt.Sprint(runtimeConfig.Port))))
-}
diff --git a/config.toml b/config.toml
index cd71cbf..55b2508 100644
--- a/config.toml
+++ b/config.toml
@@ -1,6 +1,5 @@
 default_language = "en-GB"
 base_url = "https://alanpearce.eu"
-redirect_other_hostnames = true
 
 title = "Alan Pearce"
 email = "alan@alanpearce.eu"
@@ -13,10 +12,34 @@ original_domain = "alanpearce.eu"
 name = "tags"
 feed = 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"
@@ -38,12 +61,12 @@ content-security-policy = "default-src 'none'; img-src 'self'; object-src 'none'
   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
index cc3e245..4ea99c2 100644
--- a/content/LICENSE
+++ b/content/LICENSE
@@ -1,4 +1,4 @@
-Attribution-ShareAlike 4.0 International
+Attribution 4.0 International
 
 =======================================================================
 
@@ -54,18 +54,16 @@ exhaustive, and do not form part of our licenses.
 
 =======================================================================
 
-Creative Commons Attribution-ShareAlike 4.0 International Public
-License
+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-ShareAlike 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.
+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.
@@ -84,11 +82,7 @@ Section 1 -- Definitions.
      and Similar Rights in Your contributions to Adapted Material in
      accordance with the terms and conditions of this Public License.
 
-  c. BY-SA Compatible License means a license listed at
-     creativecommons.org/compatiblelicenses, approved by Creative
-     Commons as essentially the equivalent of this Public License.
-
-  d. Copyright and Similar Rights means copyright and/or similar rights
+  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
@@ -96,33 +90,29 @@ Section 1 -- Definitions.
      specified in Section 2(b)(1)-(2) are not Copyright and Similar
      Rights.
 
-  e. Effective Technological Measures means those measures that, in the
+  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.
 
-  f. Exceptions and Limitations means fair use, fair dealing, and/or
+  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.
 
-  g. License Elements means the license attributes listed in the name
-     of a Creative Commons Public License. The License Elements of this
-     Public License are Attribution and ShareAlike.
-
-  h. Licensed Material means the artistic or literary work, database,
+  f. Licensed Material means the artistic or literary work, database,
      or other material to which the Licensor applied this Public
      License.
 
-  i. Licensed Rights means the rights granted to You subject to the
+  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.
 
-  j. Licensor means the individual(s) or entity(ies) granting rights
+  h. Licensor means the individual(s) or entity(ies) granting rights
      under this Public License.
 
-  k. Share means to provide material to the public by any means or
+  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
@@ -130,13 +120,13 @@ Section 1 -- Definitions.
      public may access the material from a place and at a time
      individually chosen by them.
 
-  l. Sui Generis Database Rights means rights other than copyright
+  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.
 
-  m. You means the individual or entity exercising the Licensed Rights
+  k. You means the individual or entity exercising the Licensed Rights
      under this Public License. Your has a corresponding meaning.
 
 
@@ -182,13 +172,7 @@ Section 2 -- Scope.
                Licensed Rights under the terms and conditions of this
                Public License.
 
-            b. Additional offer from the Licensor -- Adapted Material.
-               Every recipient of Adapted Material from You
-               automatically receives an offer from the Licensor to
-               exercise the Licensed Rights in the Adapted Material
-               under the conditions of the Adapter's License You apply.
-
-            c. No downstream restrictions. You may not offer or impose
+            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
@@ -270,24 +254,9 @@ following conditions.
           information required by Section 3(a)(1)(A) to the extent
           reasonably practicable.
 
-  b. ShareAlike.
-
-     In addition to the conditions in Section 3(a), if You Share
-     Adapted Material You produce, the following conditions also apply.
-
-       1. The Adapter's License You apply must be a Creative Commons
-          license with the same License Elements, this version or
-          later, or a BY-SA Compatible License.
-
-       2. You must include the text of, or the URI or hyperlink to, the
-          Adapter's License You apply. You may satisfy this condition
-          in any reasonable manner based on the medium, means, and
-          context in which You Share Adapted Material.
-
-       3. You may not offer or impose any additional or different terms
-          or conditions on, or apply any Effective Technological
-          Measures to, Adapted Material that restrict exercise of the
-          rights granted under the Adapter's License You apply.
+       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.
@@ -302,9 +271,8 @@ apply to Your use of the Licensed Material:
   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,
+     Rights (but not its individual contents) is Adapted Material; and
 
-     including for purposes of Section 3(b); 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.
 
diff --git a/flake.lock b/flake.lock
index 95ede6a..a9eab85 100644
--- a/flake.lock
+++ b/flake.lock
@@ -41,11 +41,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1713596654,
-        "narHash": "sha256-LJbHQQ5aX1LVth2ST+Kkse/DRzgxlVhTL1rxthvyhZc=",
+        "lastModified": 1714213793,
+        "narHash": "sha256-Yg5D5LhyAZvd3DZrQQfJAVK8K3TkUYKooFtH1ulM0mw=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "fd16bb6d3bcca96039b11aa52038fafeb6e4f4be",
+        "rev": "d6f6eb2a984f2ba9a366c31e4d36d65465683450",
         "type": "github"
       },
       "original": {
diff --git a/flake.nix b/flake.nix
index 053ce1e..85f7915 100644
--- a/flake.nix
+++ b/flake.nix
@@ -20,7 +20,6 @@
             inherit system;
             overlays = [ gomod2nix.overlays.default ];
           };
-          nativeBuildInputs = with pkgs; [ go ];
           packages = import ./nix/default.nix {
             inherit pkgs self;
           };
@@ -45,6 +44,7 @@
                 gomod2nix.packages.${system}.default
                 gci
                 netlify-cli
+                sentry-cli
               ] ++ commonShellPackages;
             };
           };
diff --git a/fly.toml b/fly.toml
index 3207d07..5b25a9d 100644
--- a/fly.toml
+++ b/fly.toml
@@ -10,7 +10,7 @@ primary_region = "ams"
   image = "registry.fly.io/alanpearce-eu"
 
 [env]
-  PORT = "3000"
+  PORT = "80"
   REDIRECT_OTHER_HOSTNAMES = "true"
   BASE_URL = "https://alanpearce.eu"
 
@@ -19,7 +19,7 @@ primary_region = "ams"
   path = "/metrics"
 
 [http_service]
-  internal_port = 3000
+  internal_port = 80
   force_https = true
   auto_stop_machines = false
   auto_start_machines = true
@@ -36,6 +36,6 @@ primary_region = "ams"
   interval = "30s"
   method = "GET"
   timeout = "1s"
-  path = "/livez"
+  path = "/health"
   [http_service.checks.headers]
     Host = "fly-internal"
diff --git a/gitlab-ci.yml b/gitlab-ci.yml
deleted file mode 100644
index d5755d9..0000000
--- a/gitlab-ci.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-image: nixpkgs/nix-flakes
-
-stages:
-  - check
-  - build
-  - deploy
-
-check:
-  stage: check
-  script:
-    - nix flake check . --print-build-logs
-
-build:
-  stage: build
-  needs:
-    - check
-  before_script:
-    - . <(nix print-dev-env .#ci)
-    - mkdir -p "/etc/containers/"
-    - echo '{"default":[{"type":"insecureAcceptAnything"}]}' > /etc/containers/policy.json
-    - just docker-image-fly
-  script:
-    - export DOCKER_TAG="$(date --utc +%Y%m%d%H%M%S)-${CI_COMMIT_SHA}"
-    - just nix-build builder
-    - just docker-image-fly
-    - just docker-inspect result --tmpdir=$TMPDIR
-    - just print-docker-tag
-    - |
-      if fly auth whoami > /dev/null 2>&1
-      then
-        fly auth docker
-        just push-to-registry --tmpdir=$TMPDIR
-        just print-docker-tag >> build.env
-      fi
-  artifacts:
-    reports:
-      dotenv: build.env
-
-deploy:
-  stage: deploy
-  needs:
-    - build
-  script:
-    - fly auth docker
-    - fly deploy --image $FLY_REGISTRY_IMAGE_TAG
-  rules:
-    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
diff --git a/go.mod b/go.mod
index d2d20e0..4e8682e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,14 +7,15 @@ require (
 	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/ansrivas/fiberprometheus/v2 v2.6.1
+	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/gofiber/contrib/fibersentry v1.0.4
-	github.com/gofiber/fiber/v2 v2.52.4
+	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
@@ -22,34 +23,27 @@ require (
 	golang.org/x/net v0.24.0
 )
 
-replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240418170242-387207ca8d01
+replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562
 
 require (
-	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
-	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/cespare/xxhash/v2 v2.3.0 // indirect
-	github.com/gofiber/adaptor/v2 v2.2.1 // 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/google/uuid v1.6.0 // indirect
-	github.com/klauspost/compress v1.17.8 // indirect
-	github.com/kr/text v0.1.0 // indirect
-	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-runewidth v0.0.15 // indirect
-	github.com/philhofer/fwd v1.1.2 // indirect
-	github.com/prometheus/client_golang v1.19.0 // indirect
-	github.com/prometheus/client_model v0.6.1 // indirect
-	github.com/prometheus/common v0.53.0 // indirect
-	github.com/prometheus/procfs v0.14.0 // indirect
-	github.com/rivo/uniseg v0.4.7 // indirect
-	github.com/tinylib/msgp v1.1.9 // indirect
-	github.com/valyala/bytebufferpool v1.0.0 // indirect
-	github.com/valyala/fasthttp v1.52.0 // indirect
-	github.com/valyala/tcplisten v1.0.0 // 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
index 2ac2e2f..1368a63 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,6 @@
+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=
@@ -5,103 +8,236 @@ github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VP
 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-20240418170242-387207ca8d01 h1:mD01zfZPrqHj7OlyU1O2gJIBRN/kgIyMueres3CHPp8=
-github.com/alanpearce/htmlformat v0.0.0-20240418170242-387207ca8d01/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/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/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM=
-github.com/ansrivas/fiberprometheus/v2 v2.6.1/go.mod h1:MloIKvy4yN6hVqlRpJ/jDiR244YnWJaQC0FIqS8A+MY=
+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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/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/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4=
-github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc=
-github.com/gofiber/contrib/fibersentry v1.0.4 h1:RjmWbv3iU9D9ApWig/5QGHX+8xqD3qZhzcQlTPBMW0w=
-github.com/gofiber/contrib/fibersentry v1.0.4/go.mod h1:UuoYCuWcxLmU0vF8hwKl3CyzbeZ9UUj1T+rW1EsP8/I=
-github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
-github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
-github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+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/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
-github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
+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_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
-github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
-github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
-github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s=
-github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+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/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU=
-github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
-github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
-github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
-github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+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=
@@ -110,17 +246,20 @@ 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.0.0-20220811171246-fbc7d0a398ab/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=
@@ -138,17 +277,45 @@ 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/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
index d2eabf0..578390e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -5,7 +5,6 @@ import (
 	"log/slog"
 	"net/url"
 	"os"
-	"strconv"
 
 	"github.com/BurntSushi/toml"
 	"github.com/pkg/errors"
@@ -31,18 +30,17 @@ func (u *URL) UnmarshalText(text []byte) (err error) {
 }
 
 type Config struct {
-	DefaultLanguage        string `toml:"default_language"`
-	BaseURL                URL    `toml:"base_url"`
-	RedirectOtherHostnames bool   `toml:"redirect_other_hostnames"`
-	Port                   uint64
-	Production             bool
-	Title                  string
-	Email                  string
-	Description            string
-	DomainStartDate        string `toml:"domain_start_date"`
-	OriginalDomain         string `toml:"original_domain"`
-	Taxonomies             []Taxonomy
-	Extra                  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
@@ -71,10 +69,5 @@ func GetConfig() (*Config, error) {
 			return nil, errors.Wrap(err, "config error")
 		}
 	}
-	port, err := strconv.ParseUint(getEnvFallback("PORT", "3000"), 10, 16)
-	if err != nil {
-		return nil, err
-	}
-	config.Port = port
 	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
index a3b655f..9dc57b8 100755
--- a/justfile
+++ b/justfile
@@ -3,6 +3,9 @@
 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
@@ -11,7 +14,7 @@ check:
     nix flake check . --print-build-logs
 
 check-licenses:
-    nix run nixpkgs#go-licenses
+    nix run nixpkgs#go-licenses check ./...
 
 update-all:
     go get -u all
@@ -21,12 +24,26 @@ update-all:
 watch-flake command:
     watchexec --restart -w flake.nix -w flake.lock direnv exec . {{ command }}
 
-watch-builder: (watch-flake "watchexec -i cmd/server -i public -r go run ./cmd/build --base-url http://localhost:3000")
+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 go run ./cmd/server")
+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
@@ -43,14 +60,24 @@ docker-inspect image-path="result" *skopeo-flags="":
     skopeo {{ skopeo-flags }} inspect docker-archive:{{ image-path }}
 
 print-docker-tag:
-    @echo {{ docker-tag }}
+    @echo {{ fly-registry }}:{{ docker-tag }}
 
-stream-to-registry *skopeo-flags="":
+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 }}
 
-deploy registry-and-tag=(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/nix/default.nix b/nix/default.nix
index 1ee61a2..de427db 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -1,14 +1,15 @@
 { pkgs, self }:
 let
-  version = self.shortRev or self.dirtyShortRev or "unstable-${self.lastModified}";
-  dockerTag = self.rev or self.dirtyRev or "unstable-${self.lastModified}";
+  version = "unstable";
+  shortSHA = self.shortRev or self.dirtyShortRev;
+  fullSHA = self.rev or self.dirtyRev;
   mkDocker = type: { server, website }:
     let
-      PORT = 3000;
+      PORT = 80;
     in
     pkgs.dockerTools.${type} {
       name = "registry.fly.io/alanpearce-eu";
-      tag = dockerTag;
+      tag = fullSHA;
       contents = [
         (pkgs.writeTextDir "config.toml" (builtins.readFile ./../config.toml))
         website
@@ -74,7 +75,15 @@ with pkgs; rec {
       ];
     };
     modules = ./gomod2nix.toml;
-    ldflags = [ "-s" "-w" "-X" "main.Commit=${version}" ];
+    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 {
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
index 0ebe538..8c6ac1f 100644
--- a/nix/gomod2nix.toml
+++ b/nix/gomod2nix.toml
@@ -8,21 +8,18 @@ schema = 3
     version = "v1.9.1"
     hash = "sha256-HlO8KL0FWs7qZk56wcVAn/y080PfK910HyIVo9y9lvM="
   [mod."github.com/a-h/htmlformat"]
-    version = "v0.0.0-20240418170242-387207ca8d01"
-    hash = "sha256-6EhDObXsE0ObvaHCPXl2pHXhKaEYr/mUZNhLPcUz3L0="
+    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/brotli"]
-    version = "v1.1.0"
-    hash = "sha256-njLViV4v++ZdgOWGWzlvkefuFvA/nkugl3Ta/h1nu/0="
   [mod."github.com/andybalholm/cascadia"]
     version = "v1.3.2"
     hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
-  [mod."github.com/ansrivas/fiberprometheus/v2"]
-    version = "v2.6.1"
-    hash = "sha256-C8WChMGD3fJucEqkUEu4kMGdP75xXCgVOLdxJu0x3jI="
+  [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="
@@ -32,87 +29,69 @@ schema = 3
   [mod."github.com/ardanlabs/conf/v3"]
     version = "v3.1.7"
     hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ="
-  [mod."github.com/beorn7/perks"]
+  [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-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
-  [mod."github.com/cespare/xxhash/v2"]
-    version = "v2.3.0"
-    hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
+    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/gofiber/adaptor/v2"]
-    version = "v2.2.1"
-    hash = "sha256-hQLeFAC3oRQA14sUK5kBfl+dbqYmULM9TA0bDgNhfp4="
-  [mod."github.com/gofiber/contrib/fibersentry"]
-    version = "v1.0.4"
-    hash = "sha256-feTWuq9aANPm16IpB1ZLZD4gZGt3Fs8Rr2d373Dlzqw="
-  [mod."github.com/gofiber/fiber/v2"]
-    version = "v2.52.4"
-    hash = "sha256-Lp6btwX5ZPo09IrCPz+f7fIztrI9W/sTULBRqAvXJu0="
+  [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/google/uuid"]
-    version = "v1.6.0"
-    hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
-  [mod."github.com/klauspost/compress"]
-    version = "v1.17.8"
-    hash = "sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs="
-  [mod."github.com/kr/text"]
-    version = "v0.1.0"
-    hash = "sha256-QT65kTrNypS5GPWGvgnCpGLPlVbQAL4IYvuqAKhepb4="
-  [mod."github.com/mattn/go-colorable"]
-    version = "v0.1.13"
-    hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
   [mod."github.com/mattn/go-isatty"]
     version = "v0.0.20"
     hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
-  [mod."github.com/mattn/go-runewidth"]
-    version = "v0.0.15"
-    hash = "sha256-WP39EU2UrQbByYfnwrkBDoKN7xzXsBssDq3pNryBGm0="
+  [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/philhofer/fwd"]
-    version = "v1.1.2"
-    hash = "sha256-N+jWn8FSjVlb/OAWmvLTm2G5/ckIkhzSPePXoeymfyA="
+  [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/prometheus/client_golang"]
-    version = "v1.19.0"
-    hash = "sha256-YV8sxMPR+xorTUCriTfcFsaV2b7PZfPJDQmOgUYOZJo="
-  [mod."github.com/prometheus/client_model"]
-    version = "v0.6.1"
-    hash = "sha256-rIDyUzNfxRA934PIoySR0EhuBbZVRK/25Jlc/r8WODw="
-  [mod."github.com/prometheus/common"]
-    version = "v0.53.0"
-    hash = "sha256-IO5DnFEYXNe5nfumAebAuiZjNaJlTiHTD0GOMqNT26o="
-  [mod."github.com/prometheus/procfs"]
-    version = "v0.14.0"
-    hash = "sha256-NZfiTx9g098TFnsA1Q/niXxTqybkbNG1BItaXSiRsnQ="
-  [mod."github.com/rivo/uniseg"]
-    version = "v0.4.7"
-    hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo="
   [mod."github.com/shengyanli1982/law"]
     version = "v0.1.13"
     hash = "sha256-gjXWxWR6XCpOUYKBzPaObw2hPOmkoVtuHd1aMHm/ljA="
-  [mod."github.com/tinylib/msgp"]
-    version = "v1.1.9"
-    hash = "sha256-SphxQenmHaFXFHofjT+d6Z9kBw55RYuyZk62PQV8/Ww="
-  [mod."github.com/valyala/bytebufferpool"]
-    version = "v1.0.0"
-    hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY="
-  [mod."github.com/valyala/fasthttp"]
-    version = "v1.52.0"
-    hash = "sha256-Gmcd4N4VOqI7Pl9Trb2ifDhaCU/AjEpuVdyNGGww5zc="
-  [mod."github.com/valyala/tcplisten"]
-    version = "v1.0.0"
-    hash = "sha256-aP0CrNH6UNRMhzgA2NgPwKyZs6xry5aDlZnLgGuHZbs="
+  [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="
@@ -131,6 +110,9 @@ schema = 3
   [mod."google.golang.org/protobuf"]
     version = "v1.33.0"
     hash = "sha256-cWwQjtUwSIEkAlAadrlxK1PYZXTRrV4NKzt7xDpJgIU="
+  [mod."gopkg.in/check.v1"]
+    version = "v1.0.0-20201130134442-10cb98267c6c"
+    hash = "sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs="
   [mod."gopkg.in/yaml.v2"]
     version = "v2.4.0"
     hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0="
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..d2c4c45
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,3 @@
+{ system ? builtins.currentSystem }:
+
+(builtins.getFlake (toString ./.)).devShells.${system}.default
diff --git a/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 63d3fbf..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>
@@ -53,10 +54,11 @@
       </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 086e139..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>
@@ -42,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 d93b766..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>
@@ -68,10 +69,11 @@ foo=bar
       </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/tags.html b/templates/tags.html
index 406f8d7..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>
@@ -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>