about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.envrc7
-rw-r--r--cmd/build/main.go8
-rw-r--r--cmd/cspgenerator/cspgenerator.go13
-rw-r--r--cmd/dev/main.go313
-rw-r--r--cmd/server/main.go50
-rw-r--r--config.toml27
-rw-r--r--flake.lock6
-rw-r--r--flake.nix1
-rw-r--r--go.mod21
-rw-r--r--go.sum226
-rw-r--r--internal/builder/builder.go (renamed from cmd/build/build.go)42
-rw-r--r--internal/builder/posts.go (renamed from cmd/build/posts.go)2
-rw-r--r--internal/builder/template.go (renamed from cmd/build/template.go)24
-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.go (renamed from cmd/server/filemap.go)2
-rw-r--r--internal/server/logging.go (renamed from cmd/server/logging.go)2
-rw-r--r--internal/server/server.go (renamed from cmd/server/server.go)89
-rwxr-xr-xjustfile11
-rw-r--r--nix/default.nix4
-rw-r--r--nix/gomod2nix.toml61
-rw-r--r--shell.nix3
-rw-r--r--templates/dev.html8
24 files changed, 973 insertions, 100 deletions
diff --git a/.envrc b/.envrc
index 3550a30..8075d5b 100644
--- a/.envrc
+++ b/.envrc
@@ -1 +1,6 @@
-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/cmd/build/main.go b/cmd/build/main.go
index 9b6a79b..069f9bd 100644
--- a/cmd/build/main.go
+++ b/cmd/build/main.go
@@ -7,6 +7,8 @@ import (
 	"log/slog"
 	"os"
 
+	"website/internal/builder"
+
 	"github.com/BurntSushi/toml"
 	"github.com/ardanlabs/conf/v3"
 	"github.com/pkg/errors"
@@ -16,9 +18,11 @@ 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 := IOConfig{}
+	ioConfig := builder.IOConfig{}
 	if help, err := conf.Parse("", &ioConfig); err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
 			fmt.Println(help)
@@ -34,7 +38,7 @@ func main() {
 		}
 	}
 
-	if err := buildSite(ioConfig); err != nil {
+	if err := builder.BuildSite(ioConfig); err != nil {
 		switch cause := errors.Cause(err).(type) {
 		case *fs.PathError:
 			slog.Info("pathError")
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
index b6817d8..bae215a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -5,7 +5,10 @@ import (
 	"log"
 	"log/slog"
 	"os"
-	cfg "website/internal/config"
+	"os/signal"
+	"sync"
+
+	"website/internal/server"
 
 	"github.com/ardanlabs/conf/v3"
 	"github.com/pkg/errors"
@@ -16,20 +19,14 @@ var (
 	ShortSHA  string
 )
 
-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"`
-}
-
 func main() {
 	if os.Getenv("DEBUG") != "" {
 		slog.SetLogLoggerLevel(slog.LevelDebug)
 	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("server: ")
 
-	runtimeConfig := Config{}
+	runtimeConfig := server.Config{}
 	help, err := conf.Parse("", &runtimeConfig)
 	if err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
@@ -39,5 +36,36 @@ func main() {
 		log.Panicf("parsing runtime configuration: %v", err)
 	}
 
-	startServer(&runtimeConfig)
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt)
+	sv, err := server.New(&runtimeConfig)
+	if err != nil {
+		log.Fatalf("error setting up server: %v", err)
+	}
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sig := <-c
+		log.Printf("signal captured: %v", sig)
+		<-sv.Stop()
+		slog.Debug("server stopped")
+	}()
+
+	sErr := make(chan error)
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sErr <- sv.Start()
+	}()
+	if !runtimeConfig.InDevServer {
+		log.Printf("server listening on %s", sv.Addr)
+	}
+
+	err = <-sErr
+	if err != nil {
+		// Error starting or closing listener:
+		log.Fatalf("error: %v", err)
+	}
+	wg.Wait()
 }
diff --git a/config.toml b/config.toml
index 603c8d3..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' https://gc.zgo.at; script-src 'self' https://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'"
 
 [[menus.main]]
     name = "Home"
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 b2e6708..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;
           };
diff --git a/go.mod b/go.mod
index ca31318..4e8682e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +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/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd
 	github.com/antchfx/xmlquery v1.4.0
 	github.com/antchfx/xpath v1.3.0
 	github.com/ardanlabs/conf/v3 v3.1.7
+	github.com/crewjam/csp v0.0.2
 	github.com/deckarep/golang-set/v2 v2.6.0
+	github.com/fatih/structtag v1.2.0
 	github.com/getsentry/sentry-go v0.27.0
+	github.com/gohugoio/hugo v0.125.4
 	github.com/otiai10/copy v1.14.0
 	github.com/pkg/errors v0.9.1
 	github.com/shengyanli1982/law v0.1.13
@@ -19,16 +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/cascadia v1.3.2 // indirect
+	github.com/bep/godartsass v1.2.0 // indirect
+	github.com/bep/godartsass/v2 v2.0.0 // indirect
+	github.com/bep/golibsass v1.1.1 // indirect
+	github.com/cli/safeexec v1.0.1 // indirect
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/gobwas/glob v0.2.3 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/kr/pretty v0.3.1 // indirect
-	github.com/rogpeppe/go-internal v1.10.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mitchellh/hashstructure v1.1.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.1 // indirect
+	github.com/spf13/afero v1.11.0 // indirect
+	github.com/spf13/cast v1.6.0 // indirect
+	github.com/tdewolff/parse/v2 v2.7.13 // indirect
 	golang.org/x/sync v0.7.0 // indirect
 	golang.org/x/sys v0.19.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 7792d9c..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,62 +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/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562 h1:7LpBXZnmFk8+RwdFnAYB7rKZhBQrQ4poPLEhpwwbmSc=
+github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
+github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
+github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
+github.com/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd h1:FD3sn3oFA0wySr3iH+47H3lIts9qT9nZfoF935hxaH0=
+github.com/antage/eventsource v0.0.0-20220422142129-c4aae935d5bd/go.mod h1:jXAZMa2S7OGjBCXWeQVOIZd+LXToszS2zCh0NiHRGvE=
 github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA=
 github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI=
 github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc=
 github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
 github.com/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0=
 github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
+github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
+github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo=
+github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/bep/gitmap v1.1.2 h1:zk04w1qc1COTZPPYWDQHvns3y1afOsdRfraFQ3qI840=
+github.com/bep/gitmap v1.1.2/go.mod h1:g9VRETxFUXNWzMiuxOwcudo6DfZkW9jOsOW0Ft4kYaY=
+github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
+github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
+github.com/bep/godartsass v1.2.0 h1:E2VvQrxAHAFwbjyOIExAMmogTItSKodoKuijNrGm5yU=
+github.com/bep/godartsass v1.2.0/go.mod h1:6LvK9RftsXMxGfsA0LDV12AGc4Jylnu6NgHL+Q5/pE8=
+github.com/bep/godartsass/v2 v2.0.0 h1:Ruht+BpBWkpmW+yAM2dkp7RSSeN0VLaTobyW0CiSP3Y=
+github.com/bep/godartsass/v2 v2.0.0/go.mod h1:AcP8QgC+OwOXEq6im0WgDRYK7scDsmZCEW62o1prQLo=
+github.com/bep/golibsass v1.1.1 h1:xkaet75ygImMYjM+FnHIT3xJn7H0xBA9UxSOJjk8Khw=
+github.com/bep/golibsass v1.1.1/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
+github.com/bep/gowebp v0.3.0 h1:MhmMrcf88pUY7/PsEhMgEP0T6fDUnRTMpN8OclDrbrY=
+github.com/bep/gowebp v0.3.0/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
+github.com/bep/lazycache v0.4.0 h1:X8yVyWNVupPd4e1jV7efi3zb7ZV/qcjKQgIQ5aPbkYI=
+github.com/bep/lazycache v0.4.0/go.mod h1:NmRm7Dexh3pmR1EignYR8PjO2cWybFQ68+QgY3VMCSc=
+github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
+github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
+github.com/bep/overlayfs v0.9.2 h1:qJEmFInsW12L7WW7dOTUhnMfyk/fN9OCDEO5Gr8HSDs=
+github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40=
+github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
+github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
+github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
+github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
+github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
+github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/crewjam/csp v0.0.2 h1:fIq6o0Z6bkABlvLT3kB0XgPnVX9iNXSAGMILs6AqHVw=
+github.com/crewjam/csp v0.0.2/go.mod h1:0tirp4wHwMLZZtV+HXRqGFkUO7uD2ux+1ECvK+7/xFI=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
 github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
+github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
+github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/evanw/esbuild v0.20.2 h1:E4Y0iJsothpUCq7y0D+ERfqpJmPWrZpNybJA3x3I4p8=
+github.com/evanw/esbuild v0.20.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
+github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
+github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
 github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
 github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q=
+github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
+github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw=
+github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI=
+github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
+github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
+github.com/gohugoio/hugo v0.125.4 h1:H4sg186C5bUbN1RCDGTNkly/TP/nUkOyxGoMTkX3z20=
+github.com/gohugoio/hugo v0.125.4/go.mod h1:b2O1TXqyxQnMzr6wUpqTWJUuK83/U9i2kfYCovO9Gb0=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0 h1:PCtO5l++psZf48yen2LxQ3JiOXxaRC6v0594NeHvGZg=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.2.0/go.mod h1:g9CCh+Ci2IMbPUrVJuXbBTrA+rIIx5+hDQ4EXYaQDoM=
+github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
+github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
+github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
+github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/hairyhenderson/go-codeowners v0.4.0 h1:Wx/tRXb07sCyHeC8mXfio710Iu35uAy5KYiBdLHdv4Q=
+github.com/hairyhenderson/go-codeowners v0.4.0/go.mod h1:iJgZeCt+W/GzXo5uchFCqvVHZY2T4TAIpvuVlKVkLxc=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
+github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
+github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
+github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kyokomi/emoji/v2 v2.2.12 h1:sSVA5nH9ebR3Zji1o31wu3yOwD1zKXQA2z0zUyeit60=
+github.com/kyokomi/emoji/v2 v2.2.12/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
+github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
+github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0=
+github.com/mitchellh/hashstructure v1.1.0/go.mod h1:xUDAozZz0Wmdiufv0uyhnHkUTN6/6d8ulp4AwfLKrmA=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
+github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
+github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
+github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
 github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
 github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
 github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
+github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
+github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
 github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-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/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=
@@ -69,17 +246,22 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -95,15 +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/cmd/build/build.go b/internal/builder/builder.go
index 5daa940..90e957c 100644
--- a/cmd/build/build.go
+++ b/internal/builder/builder.go
@@ -1,9 +1,8 @@
-package main
+package builder
 
 import (
 	"fmt"
 	"io"
-	"log"
 	"log/slog"
 	"net/url"
 	"os"
@@ -16,11 +15,19 @@ import (
 	"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")
@@ -34,6 +41,7 @@ func outputToFile(output io.Reader, filename ...string) error {
 }
 
 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")
@@ -48,7 +56,7 @@ func build(outDir string, config config.Config) error {
 		PermissionControl: cp.AddPermission(0755),
 	})
 	if err != nil {
-		log.Panic(errors.Errorf("could not copy static files: %v", err))
+		return errors.WithMessage(err, "could not copy static files")
 	}
 
 	if err := mkdirp(publicDir, "post"); err != nil {
@@ -163,34 +171,26 @@ func build(outDir string, config config.Config) error {
 	return nil
 }
 
-type IOConfig struct {
-	Source      string `conf:"default:.,short:s"`
-	Destination string `conf:"default:website,short:d"`
-	BaseURL     config.URL
-}
-
-func buildSite(ioConfig IOConfig) error {
+func BuildSite(ioConfig IOConfig) error {
 	config, err := config.GetConfig()
 	if err != nil {
-		log.Panic(errors.Errorf("could not get config: %v", err))
+		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 {
-			log.Panic(errors.Errorf("highly unlikely: %v", err))
+			return errors.WithMessage(err, "could not re-parse base URL")
 		}
 	}
 
-	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 {
-		return err
+	if ioConfig.Development && ioConfig.Destination != "website" {
+		err = os.RemoveAll(ioConfig.Destination)
+		if err != nil {
+			return errors.WithMessage(err, "could not remove destination directory")
+		}
 	}
 
-	slog.Debug("done")
-	return nil
+	return build(ioConfig.Destination, *config)
 }
diff --git a/cmd/build/posts.go b/internal/builder/posts.go
index f03caf3..223531b 100644
--- a/cmd/build/posts.go
+++ b/internal/builder/posts.go
@@ -1,4 +1,4 @@
-package main
+package builder
 
 import (
 	"bytes"
diff --git a/cmd/build/template.go b/internal/builder/template.go
index 4f7552c..74d0418 100644
--- a/cmd/build/template.go
+++ b/internal/builder/template.go
@@ -1,4 +1,4 @@
-package main
+package builder
 
 import (
 	"encoding/xml"
@@ -22,10 +22,11 @@ import (
 )
 
 var (
-	assetsOnce sync.Once
-	css        string
-	countHTML  *goquery.Document
-	templates  map[string]*os.File = make(map[string]*os.File)
+	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) {
@@ -111,6 +112,14 @@ func layout(filename string, config config.Config, pageTitle string, pageURL str
 		}
 		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
@@ -125,8 +134,11 @@ func layout(filename string, config config.Config, pageTitle string, pageURL str
 	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("\n" + string(css))
+	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()
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/cmd/server/filemap.go b/internal/server/filemap.go
index 5f7e1bb..466db49 100644
--- a/cmd/server/filemap.go
+++ b/internal/server/filemap.go
@@ -1,4 +1,4 @@
-package main
+package server
 
 import (
 	"fmt"
diff --git a/cmd/server/logging.go b/internal/server/logging.go
index 601baab..135f06e 100644
--- a/cmd/server/logging.go
+++ b/internal/server/logging.go
@@ -1,4 +1,4 @@
-package main
+package server
 
 import (
 	"fmt"
diff --git a/cmd/server/server.go b/internal/server/server.go
index 9a1e48a..cbee989 100644
--- a/cmd/server/server.go
+++ b/internal/server/server.go
@@ -1,6 +1,7 @@
-package main
+package server
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"log"
@@ -9,6 +10,8 @@ import (
 	"net"
 	"net/http"
 	"os"
+	"path"
+	"slices"
 	"strings"
 	"time"
 
@@ -16,17 +19,36 @@ import (
 
 	"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") {
@@ -52,6 +74,7 @@ func serveFile(w http.ResponseWriter, r *http.Request) *HTTPError {
 	}
 	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)
 	}
@@ -93,20 +116,28 @@ func fixupMIMETypes() {
 	}
 }
 
-func startServer(runtimeConfig *Config) {
+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()
 
-	c, err := cfg.GetConfig()
+	var err error
+	config, err = cfg.GetConfig()
 	if err != nil {
-		log.Panicf("parsing configuration file: %v", err)
+		return nil, errors.WithMessage(err, "error parsing configuration file")
+	}
+	if runtimeConfig.InDevServer {
+		applyDevModeOverrides(config)
 	}
-	config = c
 
-	prefix := "website/public"
+	prefix := path.Join(runtimeConfig.Root, "public")
 	slog.Debug("registering content files", "prefix", prefix)
 	err = registerContentFiles(prefix)
 	if err != nil {
-		log.Panicf("registering content files: %v", err)
+		return nil, errors.WithMessagef(err, "registering content files")
 	}
 
 	env := "development"
@@ -121,13 +152,14 @@ func startServer(runtimeConfig *Config) {
 		Environment:      env,
 	})
 	if err != nil {
-		log.Panic("could not set up sentry")
+		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))
@@ -143,7 +175,7 @@ func startServer(runtimeConfig *Config) {
 	} else {
 		logWriter = os.Stdout
 	}
-	http.Handle("/",
+	top.Handle("/",
 		sentryHandler.Handle(
 			wrapHandlerWithLogging(mux, wrappedHandlerOptions{
 				defaultHostname: runtimeConfig.BaseURL.Hostname(),
@@ -152,10 +184,43 @@ func startServer(runtimeConfig *Config) {
 		),
 	)
 	// no logging, no sentry
-	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+	top.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusOK)
 	})
 
-	listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, fmt.Sprint(runtimeConfig.Port))
-	log.Fatal(http.ListenAndServe(listenAddress, nil))
+	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 e4021c0..9dc57b8 100755
--- a/justfile
+++ b/justfile
@@ -34,6 +34,17 @@ nix-build what:
 
 watch-server: (watch-flake "watchexec -r -i content -i templates go run ./cmd/server")
 
+dev:
+    #!/usr/bin/env bash
+    set -euxo pipefail
+    tmp="$(mktemp -d -t website-XXXXXX)" || exit 1
+    echo using temp directory $tmp
+    trap "{ echo cleaning up $tmp; rm -rf \"$tmp\"; }" EXIT
+    go build -o $tmp ./cmd/dev ./cmd/build ./cmd/server
+    "${tmp}/dev" --temp-dir "${tmp}"
+
+watch-dev: (watch-flake "watchexec -r -e go just dev")
+
 docker-stream system=(arch() + "-linux"):
     @nix build --print-out-paths .#docker-stream-{{ system }} | sh
 
diff --git a/nix/default.nix b/nix/default.nix
index e818c5a..de427db 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -80,9 +80,9 @@ with pkgs; rec {
       "-s"
       "-w"
       "-X"
-      "main.CommitSHA=${fullSHA}"
+      "website/internal/server.CommitSHA=${fullSHA}"
       "-X"
-      "main.ShortSHA=${shortSHA}"
+      "website/internal/server.ShortSHA=${shortSHA}"
     ];
   };
   docker-stream = mkDockerStream { inherit server website; };
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
index 4a09463..8c6ac1f 100644
--- a/nix/gomod2nix.toml
+++ b/nix/gomod2nix.toml
@@ -8,8 +8,8 @@ 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"
@@ -17,6 +17,9 @@ schema = 3
   [mod."github.com/andybalholm/cascadia"]
     version = "v1.3.2"
     hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
+  [mod."github.com/antage/eventsource"]
+    version = "v0.0.0-20220422142129-c4aae935d5bd"
+    hash = "sha256-5JLLEwW3L3Mt756q3Qw0TKtrNjQ3VtEsV9S2JfdPHy0="
   [mod."github.com/antchfx/xmlquery"]
     version = "v1.4.0"
     hash = "sha256-ReWP6CPDvvWUd7vY0qIP4qyxvrotXrx9HXbGbeProP4="
@@ -26,30 +29,69 @@ schema = 3
   [mod."github.com/ardanlabs/conf/v3"]
     version = "v3.1.7"
     hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ="
+  [mod."github.com/bep/godartsass"]
+    version = "v1.2.0"
+    hash = "sha256-kkKRFesHX8Yp1+/L7yFeRqltBRlAVKgdSN4d7Lc/uI8="
+  [mod."github.com/bep/godartsass/v2"]
+    version = "v2.0.0"
+    hash = "sha256-ISvlb0UVikyLGPCrkQus//HVgAcgplQDhdIuTrmToRM="
+  [mod."github.com/bep/golibsass"]
+    version = "v1.1.1"
+    hash = "sha256-rQK/w54sh57GtCG8plKbkFxWBZB0+7RLMvOGCV2jvqY="
+  [mod."github.com/cli/safeexec"]
+    version = "v1.0.1"
+    hash = "sha256-74r/MHyMIxDSGA2D862d/OQ3lYLozxoUIRMyL0n03m8="
+  [mod."github.com/crewjam/csp"]
+    version = "v0.0.2"
+    hash = "sha256-4vlGmDdQjPiXmueCV51fJH/hRcG8eqhCi9TENCXjzfA="
   [mod."github.com/deckarep/golang-set/v2"]
     version = "v2.6.0"
     hash = "sha256-ni1XK75Q8iBBmxgoyZTedP4RmrUPzFC4978xB4HKdfs="
+  [mod."github.com/fatih/structtag"]
+    version = "v1.2.0"
+    hash = "sha256-Y2pjiEmMsxfUH8LONU2/f8k1BibOHeLKJmi4uZm/SSU="
+  [mod."github.com/fsnotify/fsnotify"]
+    version = "v1.7.0"
+    hash = "sha256-MdT2rQyQHspPJcx6n9ozkLbsktIOJutOqDuKpNAtoZY="
   [mod."github.com/getsentry/sentry-go"]
     version = "v0.27.0"
     hash = "sha256-PTkTzVNogqFA/5rc6INLY6RxK5uR1AoJFOO+pOPdE7Q="
+  [mod."github.com/gobwas/glob"]
+    version = "v0.2.3"
+    hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc="
+  [mod."github.com/gohugoio/hugo"]
+    version = "v0.125.4"
+    hash = "sha256-BHTawZLm/82SXJyGq17pIizqHCG2NJRuqTioKqcu880="
   [mod."github.com/golang/groupcache"]
     version = "v0.0.0-20210331224755-41bb18bfe9da"
     hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0="
-  [mod."github.com/kr/pretty"]
-    version = "v0.3.1"
-    hash = "sha256-DlER7XM+xiaLjvebcIPiB12oVNjyZHuJHoRGITzzpKU="
+  [mod."github.com/mattn/go-isatty"]
+    version = "v0.0.20"
+    hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
+  [mod."github.com/mitchellh/hashstructure"]
+    version = "v1.1.0"
+    hash = "sha256-dNPVpLRsCa2XZHlCRRtkpBVqb8rpHIocpFPNCqZg2EY="
   [mod."github.com/otiai10/copy"]
     version = "v1.14.0"
     hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A="
+  [mod."github.com/pelletier/go-toml/v2"]
+    version = "v2.2.1"
+    hash = "sha256-gmQ4CTz/MI97D3pYqU7mpxqo8gBTDccQ1Cp0lAMmJUc="
   [mod."github.com/pkg/errors"]
     version = "v0.9.1"
     hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
-  [mod."github.com/rogpeppe/go-internal"]
-    version = "v1.10.0"
-    hash = "sha256-vR7+d0aoKTuKeTYSgZxsGhH9e5Zvxix3Zrq9SPm5+NQ="
   [mod."github.com/shengyanli1982/law"]
     version = "v0.1.13"
     hash = "sha256-gjXWxWR6XCpOUYKBzPaObw2hPOmkoVtuHd1aMHm/ljA="
+  [mod."github.com/spf13/afero"]
+    version = "v1.11.0"
+    hash = "sha256-+rV3cDZr13N8E0rJ7iHmwsKYKH+EhV+IXBut+JbBiIE="
+  [mod."github.com/spf13/cast"]
+    version = "v1.6.0"
+    hash = "sha256-hxioqRZfXE0AE5099wmn3YG0AZF8Wda2EB4c7zHF6zI="
+  [mod."github.com/tdewolff/parse/v2"]
+    version = "v2.7.13"
+    hash = "sha256-mG6TO8hcXUmi9yKIogrfoDLWE6i6qs4LTjr7mSmqb7M="
   [mod."github.com/yuin/goldmark"]
     version = "v1.7.1"
     hash = "sha256-3EUgwoZRRs2jNBWSbB0DGNmfBvx7CeAgEwyUdaRaeR4="
@@ -65,6 +107,9 @@ schema = 3
   [mod."golang.org/x/text"]
     version = "v0.14.0"
     hash = "sha256-yh3B0tom1RfzQBf1RNmfdNWF1PtiqxV41jW1GVS6JAg="
+  [mod."google.golang.org/protobuf"]
+    version = "v1.33.0"
+    hash = "sha256-cWwQjtUwSIEkAlAadrlxK1PYZXTRrV4NKzt7xDpJgIU="
   [mod."gopkg.in/check.v1"]
     version = "v1.0.0-20201130134442-10cb98267c6c"
     hash = "sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs="
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/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>