about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.envrc1
-rw-r--r--.gitignore33
-rw-r--r--.gitlab-ci.yml23
-rw-r--r--LICENSE19
-rw-r--r--README.md8
-rw-r--r--_redirects1
-rw-r--r--cmd/build/build.go196
-rw-r--r--cmd/build/main.go52
-rw-r--r--cmd/build/posts.go121
-rw-r--r--cmd/build/template.go359
-rw-r--r--cmd/server/filemap.go77
-rw-r--r--cmd/server/logging.go55
-rw-r--r--cmd/server/main.go43
-rw-r--r--cmd/server/server.go161
-rw-r--r--config.toml62
-rw-r--r--content/LICENSE395
-rw-r--r--content/_index.md6
-rw-r--r--content/post/_index.md6
-rw-r--r--content/post/homesteading.md13
-rw-r--r--content/post/nixos-on-nanopi-r5s.md142
-rw-r--r--content/post/now-on-three-continents.md25
-rw-r--r--content/post/repository-management-with-ghq.md6
-rw-r--r--content/post/self-hosted-git.md2
-rw-r--r--default.nix10
-rw-r--r--flake.lock102
-rw-r--r--flake.nix60
-rw-r--r--fly.toml41
-rw-r--r--go.mod34
-rw-r--r--go.sum111
-rw-r--r--internal/atom/atom.go43
-rw-r--r--internal/config/config.go80
-rwxr-xr-xjustfile72
-rw-r--r--netlify/netlify.toml14
-rw-r--r--nix/default.nix102
-rw-r--r--nix/gomod2nix.toml73
-rw-r--r--static/cv/index.html348
-rw-r--r--static/robots.txt3
-rw-r--r--templates/404.html38
-rw-r--r--templates/atom.xml48
-rw-r--r--templates/count.html6
-rw-r--r--templates/feed-styles.xsl82
-rw-r--r--templates/feed.xml24
-rw-r--r--templates/homepage.html64
-rw-r--r--templates/list.html53
-rw-r--r--templates/post.html79
-rw-r--r--templates/style.css195
-rw-r--r--templates/tags.html43
-rw-r--r--themes/xmin/static/css/style.css75
-rw-r--r--themes/xmin/templates/base.html25
l---------themes/xmin/templates/categories/list.html1
l---------themes/xmin/templates/categories/single.html1
-rw-r--r--themes/xmin/templates/index.html36
-rw-r--r--themes/xmin/templates/page.html28
-rw-r--r--themes/xmin/templates/section.html18
-rw-r--r--themes/xmin/templates/tags/list.html18
-rw-r--r--themes/xmin/templates/tags/single.html25
-rw-r--r--themes/xmin/theme.toml12
57 files changed, 3459 insertions, 311 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
index a959665..2932e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,31 @@
-/public/
+# Allowlisting gitignore template for GO projects prevents us
+# from adding various unwanted local files, such as generated
+# files, developer configurations or IDE-specific files etc.
+#
+# Recommended: Go.AllowList.gitignore
 
-# Local Netlify folder
-.netlify
\ No newline at end of file
+# Ignore everything
+*
+
+# But not these files...
+!.gitignore
+
+!*.go
+!go.sum
+!go.mod
+
+!README.md
+!LICENSE
+
+!.envrc
+!justfile
+!*.nix
+!*.toml
+!/flake.lock
+
+!/content/**/*.md
+!/static/**/*
+!/templates/*
+
+# ...even if they are in subdirectories
+!*/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index 19a279c..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,23 +0,0 @@
----
-variables:
-  # This variable will ensure that the CI runner pulls in your theme from the submodule
-  GIT_SUBMODULE_STRATEGY: recursive
-
-image: nixery.dev/shell/gnugrep/git/zola
-
-test:
-  script:
-    - zola
-  except:
-    variables:
-      - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-
-pages:
-  script:
-    - zola build
-  artifacts:
-    paths:
-      - public
-  only:
-    variables:
-      - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..edbe912
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2024 Alan Pearce <alan@alanpearce.eu>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bc27bc4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# homestead
+
+## Goals
+
+1. Static web server with prometheus-based analytics
+2. Dynamic web server capable of generating Zola-based websites
+3. More indieweb features
+
diff --git a/_redirects b/_redirects
deleted file mode 100644
index 2ee3842..0000000
--- a/_redirects
+++ /dev/null
@@ -1 +0,0 @@
-/post/index.xml /atom.xml 301
\ No newline at end of file
diff --git a/cmd/build/build.go b/cmd/build/build.go
new file mode 100644
index 0000000..5daa940
--- /dev/null
+++ b/cmd/build/build.go
@@ -0,0 +1,196 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"log/slog"
+	"net/url"
+	"os"
+	"path"
+	"slices"
+
+	"website/internal/config"
+
+	cp "github.com/otiai10/copy"
+	"github.com/pkg/errors"
+)
+
+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.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 {
+		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, "/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
+}
+
+type IOConfig struct {
+	Source      string `conf:"default:.,short:s"`
+	Destination string `conf:"default:website,short:d"`
+	BaseURL     config.URL
+}
+
+func buildSite(ioConfig IOConfig) error {
+	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 {
+		return err
+	}
+
+	slog.Debug("done")
+	return nil
+}
diff --git a/cmd/build/main.go b/cmd/build/main.go
new file mode 100644
index 0000000..9b6a79b
--- /dev/null
+++ b/cmd/build/main.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+	"fmt"
+	"io/fs"
+	"log"
+	"log/slog"
+	"os"
+
+	"github.com/BurntSushi/toml"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+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")
+		}
+	}
+
+	if err := 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/build/posts.go b/cmd/build/posts.go
new file mode 100644
index 0000000..f03caf3
--- /dev/null
+++ b/cmd/build/posts.go
@@ -0,0 +1,121 @@
+package main
+
+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/cmd/build/template.go b/cmd/build/template.go
new file mode 100644
index 0000000..84cf57c
--- /dev/null
+++ b/cmd/build/template.go
@@ -0,0 +1,359 @@
+package main
+
+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
+	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 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)
+	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/cmd/server/filemap.go b/cmd/server/filemap.go
new file mode 100644
index 0000000..5f7e1bb
--- /dev/null
+++ b/cmd/server/filemap.go
@@ -0,0 +1,77 @@
+package main
+
+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/cmd/server/logging.go b/cmd/server/logging.go
new file mode 100644
index 0000000..601baab
--- /dev/null
+++ b/cmd/server/logging.go
@@ -0,0 +1,55 @@
+package main
+
+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/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..b6817d8
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"log/slog"
+	"os"
+	cfg "website/internal/config"
+
+	"github.com/ardanlabs/conf/v3"
+	"github.com/pkg/errors"
+)
+
+var (
+	CommitSHA string
+	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)
+	}
+
+	runtimeConfig := 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)
+	}
+
+	startServer(&runtimeConfig)
+}
diff --git a/cmd/server/server.go b/cmd/server/server.go
new file mode 100644
index 0000000..9a1e48a
--- /dev/null
+++ b/cmd/server/server.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"log/slog"
+	"mime"
+	"net"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	cfg "website/internal/config"
+
+	"github.com/getsentry/sentry-go"
+	sentryhttp "github.com/getsentry/sentry-go/http"
+	"github.com/shengyanli1982/law"
+)
+
+var config *cfg.Config
+
+type HTTPError struct {
+	Error   error
+	Message string
+	Code    int
+}
+
+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")
+	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 startServer(runtimeConfig *Config) {
+	fixupMIMETypes()
+
+	c, err := cfg.GetConfig()
+	if err != nil {
+		log.Panicf("parsing configuration file: %v", err)
+	}
+	config = c
+
+	prefix := "website/public"
+	slog.Debug("registering content files", "prefix", prefix)
+	err = registerContentFiles(prefix)
+	if err != nil {
+		log.Panicf("registering content files: %v", err)
+	}
+
+	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 {
+		log.Panic("could not set up sentry")
+	}
+	defer sentry.Flush(2 * time.Second)
+	sentryHandler := sentryhttp.New(sentryhttp.Options{
+		Repanic: true,
+	})
+
+	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
+	}
+	http.Handle("/",
+		sentryHandler.Handle(
+			wrapHandlerWithLogging(mux, wrappedHandlerOptions{
+				defaultHostname: runtimeConfig.BaseURL.Hostname(),
+				logger:          logWriter,
+			}),
+		),
+	)
+	// no logging, no sentry
+	http.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))
+}
diff --git a/config.toml b/config.toml
index a7ad8d8..603c8d3 100644
--- a/config.toml
+++ b/config.toml
@@ -1,48 +1,54 @@
 default_language = "en-GB"
-base_url = "https://www.alanpearce.eu"
+base_url = "https://alanpearce.eu"
+redirect_other_hostnames = true
 
 title = "Alan Pearce"
+email = "alan@alanpearce.eu"
 description = "Developer, Emacs User"
 
-generate_feed = true
-
-highlight_code = true
-highlight_theme = "ascetic-white"
-
-theme = "xmin"
+domain_start_date = "2014-06-07"
+original_domain = "alanpearce.eu"
 
 [[taxonomies]]
 name = "tags"
 feed = true
 
-[extra]
-footer = "Licensed under a <a rel=\"license\" href=\"http://creativecommons.org/licenses/by/4.0/\">Creative Commons Attribution 4.0 International License</a>."
-gpg_fingerprint = "48E6 576C 0707 388C B8BE FD0C CD4B EB92 A8D4 6583"
-gpg_url = "/public_key.asc"
-author_name = "Alan Pearce"
-author_image = "/img/me-thumb.jpg"
+[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'"
 
-[[extra.menu.main]]
+[[menus.main]]
     name = "Home"
     url = "/"
-    weight = 1
-[[extra.menu.main]]
+[[menus.main]]
     name = "Posts"
     url = "/post/"
-[[extra.menu.main]]
+[[menus.main]]
+    name = "Feed"
+    url = "/atom.xml"
+[[menus.main]]
     name = "Tags"
     url = "/tags/"
-[[extra.menu.main]]
+[[menus.main]]
     name = "Repositories"
     url = "https://git.alanpearce.eu"
 
-[[extra.menu.contact]]
-    name = "alan@alanpearce.eu"
-    url = "mailto:alan@alanpearce.eu"
-    weight = 1
-[[extra.menu.contact]]
-    name = "GitLab"
-    url = "https://gitlab.com/alanpearce"
-[[extra.menu.contact]]
-    name = "GitHub"
-    url = "https://github.com/alanpearce"
+[[menus.me]]
+  name = "Codeberg"
+  url = "https://codeberg.org/alanpearce"
+[[menus.me]]
+  name = "GitLab"
+  url = "https://gitlab.com/alanpearce/"
+[[menus.me]]
+  name = "GitHub"
+  url = "https://github.com/alanpearce/"
+[[menus.me]]
+  name = "LinkedIn"
+  url = "https://www.linkedin.com/in/alanpearceeu/"
+[[menus.me]]
+  name = "Mastodon"
+  url = "https://ieji.de/@alanpearce"
+[[menus.me]]
+  name = "BlueSky"
+  url = "https://bsky.app/profile/alanpearce.eu"
diff --git a/content/LICENSE b/content/LICENSE
new file mode 100644
index 0000000..4ea99c2
--- /dev/null
+++ b/content/LICENSE
@@ -0,0 +1,395 @@
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+     Considerations for licensors: Our public licenses are
+     intended for use by those authorized to give the public
+     permission to use material in ways otherwise restricted by
+     copyright and certain other rights. Our licenses are
+     irrevocable. Licensors should read and understand the terms
+     and conditions of the license they choose before applying it.
+     Licensors should also secure all rights necessary before
+     applying our licenses so that the public can reuse the
+     material as expected. Licensors should clearly mark any
+     material not subject to the license. This includes other CC-
+     licensed material, or material used under an exception or
+     limitation to copyright. More considerations for licensors:
+    wiki.creativecommons.org/Considerations_for_licensors
+
+     Considerations for the public: By using one of our public
+     licenses, a licensor grants the public permission to use the
+     licensed material under specified terms and conditions. If
+     the licensor's permission is not necessary for any reason--for
+     example, because of any applicable exception or limitation to
+     copyright--then that use is not regulated by the license. Our
+     licenses grant only permissions under copyright and certain
+     other rights that a licensor has authority to grant. Use of
+     the licensed material may still be restricted for other
+     reasons, including because others have copyright or other
+     rights in the material. A licensor may make special requests,
+     such as asking that all changes be marked or described.
+     Although not required by our licenses, you are encouraged to
+     respect those requests where reasonable. More considerations
+     for the public:
+    wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+  a. Adapted Material means material subject to Copyright and Similar
+     Rights that is derived from or based upon the Licensed Material
+     and in which the Licensed Material is translated, altered,
+     arranged, transformed, or otherwise modified in a manner requiring
+     permission under the Copyright and Similar Rights held by the
+     Licensor. For purposes of this Public License, where the Licensed
+     Material is a musical work, performance, or sound recording,
+     Adapted Material is always produced where the Licensed Material is
+     synched in timed relation with a moving image.
+
+  b. Adapter's License means the license You apply to Your Copyright
+     and Similar Rights in Your contributions to Adapted Material in
+     accordance with the terms and conditions of this Public License.
+
+  c. Copyright and Similar Rights means copyright and/or similar rights
+     closely related to copyright including, without limitation,
+     performance, broadcast, sound recording, and Sui Generis Database
+     Rights, without regard to how the rights are labeled or
+     categorized. For purposes of this Public License, the rights
+     specified in Section 2(b)(1)-(2) are not Copyright and Similar
+     Rights.
+
+  d. Effective Technological Measures means those measures that, in the
+     absence of proper authority, may not be circumvented under laws
+     fulfilling obligations under Article 11 of the WIPO Copyright
+     Treaty adopted on December 20, 1996, and/or similar international
+     agreements.
+
+  e. Exceptions and Limitations means fair use, fair dealing, and/or
+     any other exception or limitation to Copyright and Similar Rights
+     that applies to Your use of the Licensed Material.
+
+  f. Licensed Material means the artistic or literary work, database,
+     or other material to which the Licensor applied this Public
+     License.
+
+  g. Licensed Rights means the rights granted to You subject to the
+     terms and conditions of this Public License, which are limited to
+     all Copyright and Similar Rights that apply to Your use of the
+     Licensed Material and that the Licensor has authority to license.
+
+  h. Licensor means the individual(s) or entity(ies) granting rights
+     under this Public License.
+
+  i. Share means to provide material to the public by any means or
+     process that requires permission under the Licensed Rights, such
+     as reproduction, public display, public performance, distribution,
+     dissemination, communication, or importation, and to make material
+     available to the public including in ways that members of the
+     public may access the material from a place and at a time
+     individually chosen by them.
+
+  j. Sui Generis Database Rights means rights other than copyright
+     resulting from Directive 96/9/EC of the European Parliament and of
+     the Council of 11 March 1996 on the legal protection of databases,
+     as amended and/or succeeded, as well as other essentially
+     equivalent rights anywhere in the world.
+
+  k. You means the individual or entity exercising the Licensed Rights
+     under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+  a. License grant.
+
+       1. Subject to the terms and conditions of this Public License,
+          the Licensor hereby grants You a worldwide, royalty-free,
+          non-sublicensable, non-exclusive, irrevocable license to
+          exercise the Licensed Rights in the Licensed Material to:
+
+            a. reproduce and Share the Licensed Material, in whole or
+               in part; and
+
+            b. produce, reproduce, and Share Adapted Material.
+
+       2. Exceptions and Limitations. For the avoidance of doubt, where
+          Exceptions and Limitations apply to Your use, this Public
+          License does not apply, and You do not need to comply with
+          its terms and conditions.
+
+       3. Term. The term of this Public License is specified in Section
+          6(a).
+
+       4. Media and formats; technical modifications allowed. The
+          Licensor authorizes You to exercise the Licensed Rights in
+          all media and formats whether now known or hereafter created,
+          and to make technical modifications necessary to do so. The
+          Licensor waives and/or agrees not to assert any right or
+          authority to forbid You from making technical modifications
+          necessary to exercise the Licensed Rights, including
+          technical modifications necessary to circumvent Effective
+          Technological Measures. For purposes of this Public License,
+          simply making modifications authorized by this Section 2(a)
+          (4) never produces Adapted Material.
+
+       5. Downstream recipients.
+
+            a. Offer from the Licensor -- Licensed Material. Every
+               recipient of the Licensed Material automatically
+               receives an offer from the Licensor to exercise the
+               Licensed Rights under the terms and conditions of this
+               Public License.
+
+            b. No downstream restrictions. You may not offer or impose
+               any additional or different terms or conditions on, or
+               apply any Effective Technological Measures to, the
+               Licensed Material if doing so restricts exercise of the
+               Licensed Rights by any recipient of the Licensed
+               Material.
+
+       6. No endorsement. Nothing in this Public License constitutes or
+          may be construed as permission to assert or imply that You
+          are, or that Your use of the Licensed Material is, connected
+          with, or sponsored, endorsed, or granted official status by,
+          the Licensor or others designated to receive attribution as
+          provided in Section 3(a)(1)(A)(i).
+
+  b. Other rights.
+
+       1. Moral rights, such as the right of integrity, are not
+          licensed under this Public License, nor are publicity,
+          privacy, and/or other similar personality rights; however, to
+          the extent possible, the Licensor waives and/or agrees not to
+          assert any such rights held by the Licensor to the limited
+          extent necessary to allow You to exercise the Licensed
+          Rights, but not otherwise.
+
+       2. Patent and trademark rights are not licensed under this
+          Public License.
+
+       3. To the extent possible, the Licensor waives any right to
+          collect royalties from You for the exercise of the Licensed
+          Rights, whether directly or through a collecting society
+          under any voluntary or waivable statutory or compulsory
+          licensing scheme. In all other cases the Licensor expressly
+          reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+  a. Attribution.
+
+       1. If You Share the Licensed Material (including in modified
+          form), You must:
+
+            a. retain the following if it is supplied by the Licensor
+               with the Licensed Material:
+
+                 i. identification of the creator(s) of the Licensed
+                    Material and any others designated to receive
+                    attribution, in any reasonable manner requested by
+                    the Licensor (including by pseudonym if
+                    designated);
+
+                ii. a copyright notice;
+
+               iii. a notice that refers to this Public License;
+
+                iv. a notice that refers to the disclaimer of
+                    warranties;
+
+                 v. a URI or hyperlink to the Licensed Material to the
+                    extent reasonably practicable;
+
+            b. indicate if You modified the Licensed Material and
+               retain an indication of any previous modifications; and
+
+            c. indicate the Licensed Material is licensed under this
+               Public License, and include the text of, or the URI or
+               hyperlink to, this Public License.
+
+       2. You may satisfy the conditions in Section 3(a)(1) in any
+          reasonable manner based on the medium, means, and context in
+          which You Share the Licensed Material. For example, it may be
+          reasonable to satisfy the conditions by providing a URI or
+          hyperlink to a resource that includes the required
+          information.
+
+       3. If requested by the Licensor, You must remove any of the
+          information required by Section 3(a)(1)(A) to the extent
+          reasonably practicable.
+
+       4. If You Share Adapted Material You produce, the Adapter's
+          License You apply must not prevent recipients of the Adapted
+          Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+  a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+     to extract, reuse, reproduce, and Share all or a substantial
+     portion of the contents of the database;
+
+  b. if You include all or a substantial portion of the database
+     contents in a database in which You have Sui Generis Database
+     Rights, then the database in which You have Sui Generis Database
+     Rights (but not its individual contents) is Adapted Material; and
+
+  c. You must comply with the conditions in Section 3(a) if You Share
+     all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+  c. The disclaimer of warranties and limitation of liability provided
+     above shall be interpreted in a manner that, to the extent
+     possible, most closely approximates an absolute disclaimer and
+     waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+  a. This Public License applies for the term of the Copyright and
+     Similar Rights licensed here. However, if You fail to comply with
+     this Public License, then Your rights under this Public License
+     terminate automatically.
+
+  b. Where Your right to use the Licensed Material has terminated under
+     Section 6(a), it reinstates:
+
+       1. automatically as of the date the violation is cured, provided
+          it is cured within 30 days of Your discovery of the
+          violation; or
+
+       2. upon express reinstatement by the Licensor.
+
+     For the avoidance of doubt, this Section 6(b) does not affect any
+     right the Licensor may have to seek remedies for Your violations
+     of this Public License.
+
+  c. For the avoidance of doubt, the Licensor may also offer the
+     Licensed Material under separate terms or conditions or stop
+     distributing the Licensed Material at any time; however, doing so
+     will not terminate this Public License.
+
+  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+     License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+  a. The Licensor shall not be bound by any additional or different
+     terms or conditions communicated by You unless expressly agreed.
+
+  b. Any arrangements, understandings, or agreements regarding the
+     Licensed Material not stated herein are separate from and
+     independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+  a. For the avoidance of doubt, this Public License does not, and
+     shall not be interpreted to, reduce, limit, restrict, or impose
+     conditions on any use of the Licensed Material that could lawfully
+     be made without permission under this Public License.
+
+  b. To the extent possible, if any provision of this Public License is
+     deemed unenforceable, it shall be automatically reformed to the
+     minimum extent necessary to make it enforceable. If the provision
+     cannot be reformed, it shall be severed from this Public License
+     without affecting the enforceability of the remaining terms and
+     conditions.
+
+  c. No term or condition of this Public License will be waived and no
+     failure to comply consented to unless expressly agreed to by the
+     Licensor.
+
+  d. Nothing in this Public License constitutes or may be interpreted
+     as a limitation upon, or waiver of, any privileges and immunities
+     that apply to the Licensor or You, including from the legal
+     processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/content/_index.md b/content/_index.md
index add1ca4..7239667 100644
--- a/content/_index.md
+++ b/content/_index.md
@@ -1,9 +1,7 @@
 +++
 title = "Home"
-sort_by = "date"
-paginate_reversed = true
 +++
-<p class="p-note">
-I work as a Full-stack Developer in Berlin.  I occasionally write about Emacs and
+<p class="p-note note">
+I work as a back-end and infrastructure developer in Berlin.  I occasionally write about Emacs and
 development-related topics.
 </p>
diff --git a/content/post/_index.md b/content/post/_index.md
deleted file mode 100644
index e0d2523..0000000
--- a/content/post/_index.md
+++ /dev/null
@@ -1,6 +0,0 @@
-+++
-title = "Posts"
-sort_by = "date"
-paginate_reversed = true
-transparent = true
-+++
diff --git a/content/post/homesteading.md b/content/post/homesteading.md
new file mode 100644
index 0000000..52ce713
--- /dev/null
+++ b/content/post/homesteading.md
@@ -0,0 +1,13 @@
++++
+title = "Homesteading"
+description = "Running my own code"
+date = 2023-09-22T10:09:22.141Z
+[taxonomies]
+tags = ["website"]
++++
+
+I switched away from [Zola](https://www.getzola.org/) and made my own [static site builder](https://git.alanpearce.eu/website/tree) that uses only HTML templates. I've been wanting to do this since at least 2017, when I started to work on a [homestead project](https://git.alanpearce.eu/homestead/tree/src?h=2017), which I didn't quite finish.
+
+The recent release of [Bun](https://bun.sh/), which touts itself as an "all-in-one JavaScript toolkit" encouraged me to play around with it.  I have to say, I am surprised by how energising it was; an antidote to the "JavaScript fatigue" I've read about and definitely experienced. 
+
+I decided that I'd start by serving my site using Bun's web server, then I added site generation later. I have been intrigued by the idea of DOM templating ever since I read about it on [Camen Design](https://camendesign.com/dom_templating) [in 2012](https://camendesign.com/code/dom_templating/domtemplate_v4.rem) and I've enjoyed putting it into practice.
diff --git a/content/post/nixos-on-nanopi-r5s.md b/content/post/nixos-on-nanopi-r5s.md
new file mode 100644
index 0000000..185bd30
--- /dev/null
+++ b/content/post/nixos-on-nanopi-r5s.md
@@ -0,0 +1,142 @@
++++
+title = "Running NixOS on a NanoPi R5S"
+date = 2023-07-30T08:51:46Z
+[taxonomies]
+tags = ["nixos", "home-networking", "infrastructure"]
++++
+
+I managed to get [NixOS](https://nixos.org) running on my [NanoPi R5S](https://www.friendlyelec.com/index.php?route=product/product&product_id=287) ([FriendlyElec Wiki](https://wiki.friendlyelec.com/wiki/index.php/NanoPi_R5S)).
+
+Firstly, I flashed a pre-built stock Debian image from [inindev](https://github.com/inindev/nanopi-r5) to an SD card. This can be used as a rescue system later on. 
+
+From that SD card, I then flashed the same system onto the internal <abbr title="embedded MultiMediaCard">eMMC</abbr> Storage. I only really needed to this to ensure UBoot was correctly installed; I think there will be an easier way to do it.
+
+I had nix already installed on the <abbr title="Non-Volatile Memory Express">NVMe</abbr> <abbr title="Solid-State Drive">SSD</abbr> along with a home directory. I bind-mounted `/nix` and `/home` following the fstab I had previously set up:
+
+```conf
+UUID=replaceme  /mnt    ext4    relatime,lazytime   0 2
+/mnt/nix        /nix    none    defaults,bind       0 0
+/mnt/srv        /srv    none    defaults,bind       0 0
+/mnt/home       /home   none    defaults,bind       0 0
+```
+
+I then created a user for myself using that home directory, I had full access to nix in the new Debian environment. This meant I had access to `nixos-install`. 
+
+I wanted to use the [extlinux support in UBoot](https://u-boot.readthedocs.io/en/latest/develop/distro.html#boot-configuration-files), so I made `/mnt/boot` point to `/boot` on the <abbr>eMMC</abbr>:
+
+```sh
+mkdir /mnt/{emmc,boot}
+mount LABEL=rootfs /mnt/emmc
+mount --bind /mnt/emmc /mnt/boot
+```
+
+<aside>
+One could <em>probably</em> delete everything else on the <abbr>eMMC</abbr> and move the contents of <code>/mnt/emmc/boot</code> to <code>/mnt/emmc</code>, thus obviating the need to bind-mount <code>/boot</code>
+</aside>
+
+I ran `nixos-generate-config` as usual, which set up the mount points in `hardware-configuration.nix` correctly. `configuration.nix` needed a bit of tweaking. My first booting configuration was something like this, mostly borrowed from [Artem Boldariev's comment](https://github.com/inindev/nanopi-r5/issues/11#issue-1789308883):
+
+```nix
+{ config
+, pkgs
+, lib
+, ...
+}:
+let
+  fsTypes = [ "f2fs" "ext" "exfat" "vfat" ];
+in
+{
+  imports = [ ./hardware-configuration.nix ];
+  boot = {
+    kernelPackages = pkgs.linuxKernel.packages.linux_6_4;
+
+    # partial Rockchip related changes from Debian 12 kernel version 6.1
+    # Also, see here:
+    # https://discourse.nixos.org/t/how-to-provide-missing-headers-to-a-kernel-build/11422/3
+    kernelPatches = [
+      {
+        name = "rockchip-config.patch";
+        patch = null;
+        extraConfig = ''
+          PHY_ROCKCHIP_PCIE Y
+          PCIE_ROCKCHIP_EP y
+          PCIE_ROCKCHIP_DW_HOST y
+          ROCKCHIP_VOP2 y
+        '';
+      }
+      {
+        name = "status-leds.patch";
+        patch = null;
+        # old:
+        # LEDS_TRIGGER_NETDEV y
+        extraConfig = ''
+          LED_TRIGGER_PHY y
+          USB_LED_TRIG y
+          LEDS_BRIGHTNESS_HW_CHANGED y
+          LEDS_TRIGGER_MTD y
+        '';
+      }
+    ];
+    
+    supportedFilesystems = fsTypes;
+    initrd.supportedFilesystems = fsTypes;
+
+    initrd.availableKernelModules = [
+      ## Rockchip
+      ## Storage
+      "sdhci_of_dwcmshc"
+      "dw_mmc_rockchip"
+
+      "analogix_dp"
+      "io-domain"
+      "rockchip_saradc"
+      "rockchip_thermal"
+      "rockchipdrm"
+      "rockchip-rga"
+      "pcie_rockchip_host"
+      "phy-rockchip-pcie"
+      "phy_rockchip_snps_pcie3"
+      "phy_rockchip_naneng_combphy"
+      "phy_rockchip_inno_usb2"
+      "dwmac_rk"
+      "dw_wdt"
+      "dw_hdmi"
+      "dw_hdmi_cec"
+      "dw_hdmi_i2s_audio"
+      "dw_mipi_dsi"
+    ];
+    loader = {
+      timeout = 3;
+      grub.enable = false;
+      generic-extlinux-compatible = {
+        enable = true;
+        useGenerationDeviceTree = true;
+      };
+    };
+  };
+  # this file is from debian and should be in /boot/
+  hardware.deviceTree.name = "../../rk3568-nanopi-r5s.dtb";
+  # Most Rockchip CPUs (especially with hybrid cores) work best with "schedutil"
+  powerManagement.cpuFreqGovernor = "schedutil";
+  
+  boot.kernelParams = [
+    "console=tty1"
+    "console=ttyS2,1500000"
+    "earlycon=uart8250,mmio32,0xfe660000"
+  ];
+  # Let's blacklist the Rockchips RTC module so that the
+  # battery-powered HYM8563 (rtc_hym8563 kernel module) will be used
+  # by default
+  boot.blacklistedKernelModules = [ "rtc_rk808" ];
+
+  # ... typical config omitted for brevity
+}
+```
+
+Due to the custom kernel configuration, building takes a while. I set up a [distributed build](https://nixos.org/manual/nix/stable/advanced-topics/distributed-builds.html) to speed things up, using a [Hetzner Cloud](https://www.hetzner.com/cloud) CAX21 ARM64 instance (although I could have used an x86_64 system with one of the methods mentioned on the [NixOS on ARM NixOS wiki page](https://nixos.wiki/wiki/NixOS_on_ARM#Build_your_own_image_natively)). This made for a very long `nixos-install` command line:
+
+```sh
+sudo env PATH=$PATH =nixos-install --root /mnt --no-channel-copy --channel https://nixos.org/channels/nixos-23.05 --option builders'ssh://my-host aarch64-linux /root/.ssh/id_pappel_nixpkgs 4 2 big-parallel' --option builders-use-substitutes true --max-jobs 0
+```
+
+I added `setenv bootmeths "extlinux"` to `/boot/boot.txt` and ran `/boot/mkscr.sh` as root to ensure that UBoot would search for the `extlinux.conf` file
diff --git a/content/post/now-on-three-continents.md b/content/post/now-on-three-continents.md
new file mode 100644
index 0000000..1a2828a
--- /dev/null
+++ b/content/post/now-on-three-continents.md
@@ -0,0 +1,25 @@
++++
+title = "Now on three continents"
+description = "This website is now hosted on three continents"
+date = 2023-07-02T07:55:35Z
+[taxonomies]
+tags = ["website", "infrastructure"]
++++
+
+This website is now hosted on three continents.
+
+I recently changed the hosting for this site to [fly](http://fly.io), since I was rather intrigued by the idea of being able to run three small <abbr>VMs</abbr> (<dfn id="VMs">Virtual Machines</dfn>) worldwide for free. I would gladly have paid a small amount for their services. If they didn't have a free allowance for <abbr>VMs</abbr> then it would only be around $6 a month, so I'm not worried about them removing the free allowance.
+
+Previously it was running on one [Hetzner](https://www.hetzner.com) <abbr title="Virtual Machine">VM</abbr> in Nuremberg, Germany that I set up and maintained myself. The maintenance wasn't a problem for me, but rather the idea of slow loading times for anyone reading this outside of Europe.
+
+American visitors should notice a definite speedup now, as there's a server on the west coast and for the few visitors in the Asia-Pacific region, there's also a server in Australia. I kept track of the response time before and after the change using the [Online or not](https://onlineornot.com/) [Do I need a CDN?](https://onlineornot.com/do-i-need-a-cdn) tool, which you can see in the table below (measured in <abbr title="milliseconds">ms</abbr>)
+
+| Region                  | Before | After |
+|-------------------------|--------|-------|
+| Europe (Frankfurt)      | 62     | 32    |
+| US East (N. Virginia)   | 348    | 185   |
+| US West (N. California) | 503    | 61    |
+| Asia Pacific (Tokyo)    | 732    | 251   |
+| Asia Pacific (Sydney)   | 1114   | 76    |
+
+I do find it rather amusing that I spend more time tinkering with the site than actually posting anything, but, for once, tinkering has actually led to me posting something (this post). I would like to think that this might encourage me to post more in the future, but only time will tell.
diff --git a/content/post/repository-management-with-ghq.md b/content/post/repository-management-with-ghq.md
index c225ace..dd21db9 100644
--- a/content/post/repository-management-with-ghq.md
+++ b/content/post/repository-management-with-ghq.md
@@ -67,10 +67,10 @@ sequence:
 bindkey '\es' cd-project-widget
 ```
 
-Now I can press `M-s` in a shell, start typing "dotfiles" and press enter to `cd`
-to my [dotfiles][] project. Pretty neat!
+Now I can press `M-s` in a shell, start typing "nixfiles" and press enter to `cd`
+to my [nixfiles][] project. Pretty neat!
 
 [ghq]:https://github.com/motemen/ghq
 [fzf]:https://github.com/junegunn/fzf
 [fzf-cd-widget]:https://github.com/junegunn/fzf/blob/337cdbb37c1efc49b09b4cacc6e9ee1369c7d76d/shell/key-bindings.zsh#L40-L54
-[dotfiles]:https://git.alanpearce.eu/dotfiles
+[nixfiles]:https://git.alanpearce.eu/dotfiles
diff --git a/content/post/self-hosted-git.md b/content/post/self-hosted-git.md
index 3bdbffb..ab88e78 100644
--- a/content/post/self-hosted-git.md
+++ b/content/post/self-hosted-git.md
@@ -144,4 +144,4 @@ I want, without consuming many system resources with daemons.
 [dotfiles-github]:https://github.com/alanpearce/dotfiles
 [wildrepos]:http://gitolite.com/gitolite/wild/
 [ghq]:https://github.com/motemen/ghq
-[using-ghq]:{{< relref "/post/repository-management-with-ghq.md" >}} "Repository management with ghq"
+[using-ghq]:/post/repository-management-with-ghq/ "Repository management with ghq"
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..2cccff2
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,10 @@
+(import
+  (
+    let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
+    fetchTarball {
+      url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
+      sha256 = lock.nodes.flake-compat.locked.narHash;
+    }
+  )
+  { src = ./.; }
+).defaultNix
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..95ede6a
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,102 @@
+{
+  "nodes": {
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1696426674,
+        "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
+    "gomod2nix": {
+      "inputs": {
+        "flake-utils": [
+          "utils"
+        ],
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1710154385,
+        "narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=",
+        "owner": "tweag",
+        "repo": "gomod2nix",
+        "rev": "872b63ddd28f318489c929d25f1f0a3c6039c971",
+        "type": "github"
+      },
+      "original": {
+        "owner": "tweag",
+        "repo": "gomod2nix",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1713596654,
+        "narHash": "sha256-LJbHQQ5aX1LVth2ST+Kkse/DRzgxlVhTL1rxthvyhZc=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "fd16bb6d3bcca96039b11aa52038fafeb6e4f4be",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "gomod2nix": "gomod2nix",
+        "nixpkgs": "nixpkgs",
+        "utils": "utils"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    },
+    "utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1710146030,
+        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..b2e6708
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,60 @@
+{
+  description = "My website, alanpearce.eu";
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+  inputs.utils.url = "github:numtide/flake-utils";
+  inputs.flake-compat = {
+    url = "github:edolstra/flake-compat";
+    flake = false;
+  };
+  inputs.gomod2nix = {
+    url = "github:tweag/gomod2nix";
+    inputs.nixpkgs.follows = "nixpkgs";
+    inputs.flake-utils.follows = "utils";
+  };
+
+  outputs = { self, nixpkgs, utils, gomod2nix, ... }:
+    utils.lib.eachDefaultSystem
+      (system:
+        let
+          pkgs = import nixpkgs {
+            inherit system;
+            overlays = [ gomod2nix.overlays.default ];
+          };
+          nativeBuildInputs = with pkgs; [ go ];
+          packages = import ./nix/default.nix {
+            inherit pkgs self;
+          };
+          commonShellPackages = with pkgs; [
+            just
+            skopeo
+            flyctl
+          ];
+        in
+        {
+          inherit packages;
+          devShells = {
+            ci = pkgs.mkShell {
+              packages = commonShellPackages;
+            };
+            default = pkgs.mkShell {
+              inputsFrom = [ packages.builder ];
+              packages = with pkgs; [
+                gopls
+                gotools
+                go-tools
+                gomod2nix.packages.${system}.default
+                gci
+                netlify-cli
+                sentry-cli
+              ] ++ commonShellPackages;
+            };
+          };
+          checks = rec {
+            default = hyperlink;
+            hyperlink = pkgs.runCommandLocal "hyperlink" { } ''
+              ${pkgs.hyperlink}/bin/hyperlink ${packages.website}/website/public
+              touch $out
+            '';
+          };
+        });
+}
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..5b25a9d
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,41 @@
+# fly.toml app configuration file generated for homestead on 2023-09-14T11:40:37+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = "alanpearce-eu"
+primary_region = "ams"
+
+[build]
+  image = "registry.fly.io/alanpearce-eu"
+
+[env]
+  PORT = "80"
+  REDIRECT_OTHER_HOSTNAMES = "true"
+  BASE_URL = "https://alanpearce.eu"
+
+[metrics]
+  port = 9091
+  path = "/metrics"
+
+[http_service]
+  internal_port = 80
+  force_https = true
+  auto_stop_machines = false
+  auto_start_machines = true
+  min_machines_running = 3
+  processes = ["app"]
+  [http_service.concurrency]
+    type = "requests"
+    hard_limit = 20000
+    soft_limit = 15000
+[http_service.http_options.response]
+  pristine = true
+[[http_service.checks]]
+  grace_period = "15s"
+  interval = "30s"
+  method = "GET"
+  timeout = "1s"
+  path = "/health"
+  [http_service.checks.headers]
+    Host = "fly-internal"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d124c22
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,34 @@
+module website
+
+go 1.22.1
+
+require (
+	github.com/BurntSushi/toml v1.3.2
+	github.com/PuerkitoBio/goquery v1.9.1
+	github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e
+	github.com/adrg/frontmatter v0.2.0
+	github.com/antchfx/xmlquery v1.4.0
+	github.com/antchfx/xpath v1.3.0
+	github.com/ardanlabs/conf/v3 v3.1.7
+	github.com/deckarep/golang-set/v2 v2.6.0
+	github.com/getsentry/sentry-go v0.27.0
+	github.com/otiai10/copy v1.14.0
+	github.com/pkg/errors v0.9.1
+	github.com/shengyanli1982/law v0.1.13
+	github.com/yuin/goldmark v1.7.1
+	golang.org/x/net v0.24.0
+)
+
+replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562
+
+require (
+	github.com/andybalholm/cascadia v1.3.2 // indirect
+	github.com/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
+	golang.org/x/sync v0.7.0 // indirect
+	golang.org/x/sys v0.19.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..68fbd04
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,111 @@
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
+github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
+github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4=
+github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE=
+github.com/alanpearce/htmlformat v0.0.0-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/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
+github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
+github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA=
+github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI=
+github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc=
+github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
+github.com/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0=
+github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+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/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
+github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
+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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+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/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/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/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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+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=
+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/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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/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-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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
+golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/atom/atom.go b/internal/atom/atom.go
new file mode 100644
index 0000000..f2ca4a9
--- /dev/null
+++ b/internal/atom/atom.go
@@ -0,0 +1,43 @@
+package atom
+
+import (
+	"encoding/xml"
+	"time"
+
+	. "website/internal/config"
+)
+
+func MakeTagURI(config Config, specific string) string {
+	return "tag:" + config.OriginalDomain + "," + config.DomainStartDate + ":" + specific
+}
+
+type Link struct {
+	XMLName xml.Name `xml:"link"`
+	Rel     string   `xml:"rel,attr"`
+	Type    string   `xml:"type,attr"`
+	Href    string   `xml:"href,attr"`
+}
+
+func MakeLink(url string) Link {
+	return Link{
+		Rel:  "alternate",
+		Type: "text/html",
+		Href: url,
+	}
+}
+
+type FeedContent struct {
+	Content string `xml:",innerxml"`
+	Type    string `xml:"type,attr"`
+}
+
+type FeedEntry struct {
+	XMLName xml.Name    `xml:"entry"`
+	Title   string      `xml:"title"`
+	Link    Link        `xml:"link"`
+	Id      string      `xml:"id"`
+	Updated time.Time   `xml:"updated"`
+	Summary string      `xml:"summary,omitempty"`
+	Content FeedContent `xml:"content"`
+	Author  string      `xml:"author>name"`
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..d2eabf0
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,80 @@
+package config
+
+import (
+	"io/fs"
+	"log/slog"
+	"net/url"
+	"os"
+	"strconv"
+
+	"github.com/BurntSushi/toml"
+	"github.com/pkg/errors"
+)
+
+type Taxonomy struct {
+	Name string
+	Feed bool
+}
+
+type MenuItem struct {
+	Name string
+	URL  string `toml:"url"`
+}
+
+type URL struct {
+	*url.URL
+}
+
+func (u *URL) UnmarshalText(text []byte) (err error) {
+	u.URL, err = url.Parse(string(text))
+	return err
+}
+
+type Config struct {
+	DefaultLanguage        string `toml:"default_language"`
+	BaseURL                URL    `toml:"base_url"`
+	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 {
+		Headers map[string]string
+	}
+	Menus map[string][]MenuItem
+}
+
+func getEnvFallback(key, fallback string) string {
+	if value, ok := os.LookupEnv(key); ok {
+		return value
+	} else {
+		return fallback
+	}
+}
+
+func GetConfig() (*Config, error) {
+	config := Config{}
+	slog.Debug("reading config.toml")
+	_, err := toml.DecodeFile("config.toml", &config)
+	if err != nil {
+		var pathError *fs.PathError
+		var tomlError toml.ParseError
+		if errors.As(err, &pathError) {
+			return nil, errors.WithMessage(err, "could not read configuration")
+		} else if errors.As(err, &tomlError) {
+			return nil, errors.WithMessage(err, tomlError.ErrorWithUsage())
+		} else {
+			return nil, errors.Wrap(err, "config error")
+		}
+	}
+	port, err := strconv.ParseUint(getEnvFallback("PORT", "3000"), 10, 16)
+	if err != nil {
+		return nil, err
+	}
+	config.Port = port
+	return &config, nil
+}
diff --git a/justfile b/justfile
new file mode 100755
index 0000000..e4021c0
--- /dev/null
+++ b/justfile
@@ -0,0 +1,72 @@
+#! /usr/bin/env -S nix develop . --command just --justfile
+
+fly-system := "x86_64-linux"
+fly-registry := "registry.fly.io/alanpearce-eu"
+docker-tag := env_var_or_default("DOCKER_TAG", `git rev-parse HEAD`)
+version := `sentry-cli releases propose-version`
+environment := "production"
+started-at := `date +%s`
+
+default:
+    @just --list --justfile {{ justfile() }} --unsorted
+
+check:
+    nix flake check . --print-build-logs
+
+check-licenses:
+    nix run nixpkgs#go-licenses check ./...
+
+update-all:
+    go get -u all
+    gomod2nix --outdir nix
+    nix flake update
+
+watch-flake command:
+    watchexec --restart -w flake.nix -w flake.lock direnv exec . {{ command }}
+
+watch-builder: (watch-flake "watchexec -w cmd/build -w content -w templates -r go run ./cmd/build --base-url http://localhost:3000")
+
+generate:
+    go run ./cmd/build --base-url http://localhost:3000
+
+nix-build what:
+    nix build .#{{ what }}
+
+watch-server: (watch-flake "watchexec -r -i content -i templates go run ./cmd/server")
+
+docker-stream system=(arch() + "-linux"):
+    @nix build --print-out-paths .#docker-stream-{{ system }} | sh
+
+docker-image system=(arch() + "-linux"):
+    nix build .#docker-image-{{ system }}
+
+docker-stream-fly:
+    just docker-stream {{ fly-system }}
+
+docker-image-fly: (docker-image fly-system)
+
+docker-inspect image-path="result" *skopeo-flags="":
+    skopeo {{ skopeo-flags }} inspect docker-archive:{{ image-path }}
+
+print-docker-tag:
+    @echo {{ fly-registry }}:{{ docker-tag }}
+
+stream-to-registry *skopeo-flags="": sentry-create-release && sentry-finalise-release
+    just docker-stream-fly | gzip --fast | skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive:/dev/stdin docker://{{ fly-registry }}:{{ docker-tag }}
+
+result := `readlink -f result`
+push-to-registry *skopeo-flags="":
+    skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive://{{ result }}  docker://{{ fly-registry }}:{{ docker-tag }}
+
+sentry-create-release:
+    sentry-cli releases new {{ version }}
+
+sentry-finalise-release:
+    sentry-cli releases set-commits {{ version }} --ignore-missing --auto # will not work in CI
+    sentry-cli releases finalize {{ version }}
+
+sentry-create-deploy:
+    sentry-cli releases deploys {{ version }} new --started {{ started-at }} --finished `date +%s` --env {{ environment }}
+
+deploy registry-and-tag=(fly-registry + ":" + docker-tag): && sentry-create-deploy
+    fly deploy --image {{ registry-and-tag }}
diff --git a/netlify/netlify.toml b/netlify/netlify.toml
new file mode 100644
index 0000000..7b203eb
--- /dev/null
+++ b/netlify/netlify.toml
@@ -0,0 +1,14 @@
+[build]
+  base = "netlify"
+  publish = "."
+
+[[redirects]]
+  from = "*"
+  to = "https://alanpearce.eu/:splat"
+  status = 301
+  force = true
+
+[[headers]]
+  for = "/*"
+  [headers.values]
+    cache-control = "max-age=86400"
diff --git a/nix/default.nix b/nix/default.nix
new file mode 100644
index 0000000..e818c5a
--- /dev/null
+++ b/nix/default.nix
@@ -0,0 +1,102 @@
+{ pkgs, self }:
+let
+  version = "unstable";
+  shortSHA = self.shortRev or self.dirtyShortRev;
+  fullSHA = self.rev or self.dirtyRev;
+  mkDocker = type: { server, website }:
+    let
+      PORT = 80;
+    in
+    pkgs.dockerTools.${type} {
+      name = "registry.fly.io/alanpearce-eu";
+      tag = fullSHA;
+      contents = [
+        (pkgs.writeTextDir "config.toml" (builtins.readFile ./../config.toml))
+        website
+      ];
+      config = {
+        Cmd = [ "${server}/bin/server" ];
+        Env = [
+          "PRODUCTION=true"
+          "LISTEN_ADDRESS=::"
+          "PORT=${builtins.toString PORT}"
+        ];
+        ExposedPorts = {
+          "${builtins.toString PORT}/tcp" = { };
+        };
+      };
+    };
+  mkDockerStream = mkDocker "streamLayeredImage";
+  mkDockerImage = mkDocker "buildLayeredImage";
+in
+with pkgs; rec {
+  default = server;
+  builder = buildGoApplication {
+    pname = "website-builder";
+    inherit version;
+    CGO_ENABLED = 0;
+    src = with lib.fileset; toSource {
+      root = ./..;
+      fileset = unions [
+        ./../go.mod
+        ./../go.sum
+        ./../cmd/build
+        ./../internal
+      ];
+    };
+    modules = ./gomod2nix.toml;
+    subPackages = [ "cmd/build" ];
+  };
+  website = runCommandLocal "build"
+    {
+      src = with lib.fileset; toSource {
+        root = ./..;
+        fileset = unions [
+          ./../config.toml
+          ./../content
+          ./../static
+          ./../templates
+        ];
+      };
+    } ''
+    ${builder}/bin/build -s $src -d $out/website
+  '';
+  server = buildGoApplication {
+    pname = "server";
+    inherit version;
+    CGO_ENABLED = 0;
+    src = with lib.fileset; toSource {
+      root = ./..;
+      fileset = unions [
+        ./../go.mod
+        ./../go.sum
+        ./../cmd/server
+        ./../internal
+      ];
+    };
+    modules = ./gomod2nix.toml;
+    subPackages = [ "cmd/server" ];
+    ldflags = [
+      "-s"
+      "-w"
+      "-X"
+      "main.CommitSHA=${fullSHA}"
+      "-X"
+      "main.ShortSHA=${shortSHA}"
+    ];
+  };
+  docker-stream = mkDockerStream { inherit server website; };
+  docker-stream-aarch64-linux = mkDockerStream {
+    inherit website; server = (self.packages.aarch64-linux.server);
+  };
+  docker-stream-x86_64-linux = mkDockerStream {
+    inherit website; server = (self.packages.x86_64-linux.server);
+  };
+  docker-image = mkDockerImage { inherit server website; };
+  docker-image-aarch64-linux = mkDockerImage {
+    inherit website; server = (self.packages.aarch64-linux.server);
+  };
+  docker-image-x86_64-linux = mkDockerImage {
+    inherit website; server = (self.packages.x86_64-linux.server);
+  };
+}
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
new file mode 100644
index 0000000..1e2501d
--- /dev/null
+++ b/nix/gomod2nix.toml
@@ -0,0 +1,73 @@
+schema = 3
+
+[mod]
+  [mod."github.com/BurntSushi/toml"]
+    version = "v1.3.2"
+    hash = "sha256-FIwyH67KryRWI9Bk4R8s1zFP0IgKR4L66wNQJYQZLeg="
+  [mod."github.com/PuerkitoBio/goquery"]
+    version = "v1.9.1"
+    hash = "sha256-HlO8KL0FWs7qZk56wcVAn/y080PfK910HyIVo9y9lvM="
+  [mod."github.com/a-h/htmlformat"]
+    version = "v0.0.0-20240425000139-1244374b2562"
+    hash = "sha256-qvnbf/VCR2s2VmyPaQeHLkpA01MNy1g1U0l9B9maBcE="
+    replaced = "github.com/alanpearce/htmlformat"
+  [mod."github.com/adrg/frontmatter"]
+    version = "v0.2.0"
+    hash = "sha256-WJsVcdCpkIkjqUz5fJOFStZYwQlrcFzQ6+mZatZiimo="
+  [mod."github.com/andybalholm/cascadia"]
+    version = "v1.3.2"
+    hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
+  [mod."github.com/antchfx/xmlquery"]
+    version = "v1.4.0"
+    hash = "sha256-ReWP6CPDvvWUd7vY0qIP4qyxvrotXrx9HXbGbeProP4="
+  [mod."github.com/antchfx/xpath"]
+    version = "v1.3.0"
+    hash = "sha256-SU+Tnf5c9vsDCrY1BVKjqYLhB91xt9oHBS5bicbs2cA="
+  [mod."github.com/ardanlabs/conf/v3"]
+    version = "v3.1.7"
+    hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ="
+  [mod."github.com/deckarep/golang-set/v2"]
+    version = "v2.6.0"
+    hash = "sha256-ni1XK75Q8iBBmxgoyZTedP4RmrUPzFC4978xB4HKdfs="
+  [mod."github.com/getsentry/sentry-go"]
+    version = "v0.27.0"
+    hash = "sha256-PTkTzVNogqFA/5rc6INLY6RxK5uR1AoJFOO+pOPdE7Q="
+  [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/otiai10/copy"]
+    version = "v1.14.0"
+    hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A="
+  [mod."github.com/pkg/errors"]
+    version = "v0.9.1"
+    hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
+  [mod."github.com/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/yuin/goldmark"]
+    version = "v1.7.1"
+    hash = "sha256-3EUgwoZRRs2jNBWSbB0DGNmfBvx7CeAgEwyUdaRaeR4="
+  [mod."golang.org/x/net"]
+    version = "v0.24.0"
+    hash = "sha256-w1c21ljta5wNIyel9CSIn/crPzwOCRofNKhqmfs4aEQ="
+  [mod."golang.org/x/sync"]
+    version = "v0.7.0"
+    hash = "sha256-2ETllEu2GDWoOd/yMkOkLC2hWBpKzbVZ8LhjLu0d2A8="
+  [mod."golang.org/x/sys"]
+    version = "v0.19.0"
+    hash = "sha256-cmuL31TYLJmDm/fDnI2Sn0wB88cpdOHV1+urorsJWx4="
+  [mod."golang.org/x/text"]
+    version = "v0.14.0"
+    hash = "sha256-yh3B0tom1RfzQBf1RNmfdNWF1PtiqxV41jW1GVS6JAg="
+  [mod."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/static/cv/index.html b/static/cv/index.html
new file mode 100644
index 0000000..4fef4cc
--- /dev/null
+++ b/static/cv/index.html
@@ -0,0 +1,348 @@
+<!doctype html>
+<html>
+  <head>
+    <title>Alan Pearce's Curriculum Vitae</title>
+    <style>
+      body {
+        font-family: Verdana, sans-serif;
+        font-size: small;
+        margin: auto;
+        padding: 1em;
+        max-width: 50rem;
+        text-align: left;
+        background-color: #fff;
+        word-wrap: break-word;
+        overflow-wrap: break-word;
+        line-height: 1.5;
+        color: #444;
+        height: 210mm;
+        width: 297mm;
+      }
+
+      @page {
+        size: A4 portrait;
+      }
+
+      h1,
+      h2,
+      h3,
+      h4,
+      h5,
+      h6,
+      strong,
+      b {
+        color: #222;
+        margin: unset;
+      }
+
+      a {
+        color: #3273dc;
+      }
+
+      .title {
+        color: #222;
+        text-decoration: none;
+        border: 0;
+      }
+
+      time {
+        font-style: italic;
+      }
+
+      nav a {
+        margin-right: 1ex;
+      }
+
+      .tags {
+        padding: unset;
+        font-size: smaller;
+      }
+
+      .tags > li {
+        list-style: none;
+        display: inline-block;
+        padding-right: 1ex;
+      }
+
+      textarea {
+        width: 100%;
+        font-size: 1rem;
+      }
+
+      input {
+        font-size: 1rem;
+      }
+
+      main,
+      article {
+        line-height: 1.6;
+      }
+
+      blockquote {
+        border-left: 1px solid #999;
+        color: #222;
+        padding-left: 20px;
+        font-style: italic;
+      }
+
+      footer {
+        padding: 25px;
+        text-align: center;
+      }
+
+      main {
+        column-count: 2;
+      }
+      main > section {
+        padding-right: 1rem;
+        padding: 1rem 0;
+        border-bottom: 2px solid #999;
+        break-inside: avoid;
+      }
+      section > header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+      }
+      .timeperiod {
+        font-style: italic;
+        font-size: small;
+      }
+
+      ul {
+        padding-left: 0;
+        margin: unset;
+      }
+      ul > li {
+        display: inline-block;
+        font-size: smaller;
+      }
+
+      .links > li {
+        display: block;
+      }
+
+      @media (prefers-color-scheme: dark) {
+        body {
+          background-color: #333;
+          color: #ddd;
+        }
+
+        h1,
+        h2,
+        h3,
+        h4,
+        h5,
+        h6,
+        strong,
+        b,
+        .title {
+          color: #eee;
+        }
+
+        a {
+          color: #8cc2dd;
+        }
+        blockquote {
+          color: #ccc;
+        }
+        section {
+          border-bottom-color: #ccc;
+        }
+      }
+    </style>
+  </head>
+  <body>
+    <header>
+      <h1>Alan Pearce</h1>
+      <h2>Backend & Infrastructure Developer</h2>
+      <h3>Berlin, Germany</h3>
+    </header>
+    <hr />
+    <main>
+      <section>
+        <ul class="links">
+          <li>
+            Email: <a href="mailto:alan@alanpearce.eu">alan@alanpearce.eu</a>
+          </li>
+          <li>Website: <a href="https://alanpearce.eu">alanpearce.eu</a></li>
+          <li>
+            GitHub: <a href="https://github.com/alanpearce">@alanpearce</a>
+          </li>
+          <li>
+            Personal Projects:
+            <a href="https://git.alanpearce.eu">git.alanpearce.eu</a>
+          </li>
+        </ul>
+      </section>
+      <section>
+        <h4>Summary</h4>
+        <p>
+          I care about keeping code and UIs consistent and simple. I also have a
+          strong drive to learn and really enjoy being able to explore new
+          methodologies and languages.
+        </p>
+      </section>
+      <section>
+        <h4>Experience</h4>
+        <header>
+          <h5>Senior Fullstack Developer at SatoshiPay</h5>
+          <span class="timeperiod">2017—2023</span>
+        </header>
+        <ul>
+          <li>Helm</li>
+          <li>Kubernetes</li>
+          <li>GitLab</li>
+          <li>TypeScript</li>
+          <li>PostgreSQL</li>
+        </ul>
+        <p>
+          Principal worker for migration from Docker Cloud to Kubernetes,
+          alongside work on microservices interfacing with distributed ledger
+          APIs. Implemented and maintained GitLab CI/CD pipelines including
+          merge request previews and end-to-end testing. Migrated projects to
+          product-based monorepos.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Fullstack Developer at SpotCap</h5>
+          <span class="timeperiod">2015–2017</span>
+        </header>
+        <ul>
+          <li>NodeJS</li>
+          <li>MySQL</li>
+          <li>Webpack</li>
+          <li>Sails.js</li>
+          <li>Mithril.js</li>
+        </ul>
+        <p>
+          Responsible for banking integration service, implemented parsers and
+          generators for custom text formats (MT940, MT942) using unit tests to
+          verify.
+          <br />
+          Worked on backend credit scoring admin panel, began migration from
+          Sails to SPA using Mithril
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Web Developer at StudentCrowd (Studio-40 spin-off)</h5>
+          <span class="timeperiod">2014–2015</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>ElasticSearch</li>
+          <li>Vagrant</li>
+          <li>Saltstack</li>
+        </ul>
+        <p>
+          Optimised database access and ORM usage. Simplified dev environment
+          setup using Vagrant and Salt. Attended ElasticSearch, LogStash &
+          Kibana training. Worked remotely (60% -> 100%)
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Senior Developer at Studio-40</h5>
+          <span class="timeperiod">2014</span>
+        </header>
+        <ul>
+          <li>Symfony</li>
+          <li>Sylius</li>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>Capistrano</li>
+        </ul>
+        <p>
+          Wrote product CSV importer for Sylius with streaming preview diff
+          feature. Fixed issues with integration of payment provider API
+          including false payment failures. Assisted front-end developers with
+          JavaScript.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Backend Web Developer at Bulb Studios</h5>
+          <span class="timeperiod">2013–2014</span>
+        </header>
+        <ul>
+          <li>Laravel</li>
+          <li>ExpressionEngine</li>
+          <li>Ansible</li>
+          <li>PHP</li>
+          <li>Capistrano</li>
+        </ul>
+        <p>
+          Suggested and implemented switch from Apache to Nginx, enabling a
+          1000x speedup in page loads. Suggested and implemented use of
+          configuration management for server provisioning. Introduced Vagrant
+          to reduce development environment variance and Capistrano for
+          deployment. Created time-basic competition entry API designed for 50k
+          RPM.
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>PHP Web Developer at Supplyant</h5>
+          <span class="timeperiod">2012-2013</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <li>Subversion</li>
+          <li>jQuery</li>
+          <li>HTML</li>
+          <li>CSS</li>
+        </ul>
+        <p>
+          Maintained e-commerce platform and worked on new product management
+          system. Made Entity-Attribute-Value system usable for other database
+          consumers using an SQL view. Recommended use of Mustache templates,
+          which the design team loved
+        </p>
+      </section>
+      <section>
+        <header>
+          <h5>Web Applications Programmer at ASL Holdings</h5>
+          <span class="timeperiod">2010-2011</span>
+        </header>
+        <ul>
+          <li>PHP</li>
+          <li>MySQL</li>
+          <p>Continued rewrite of SIM management web application</p>
+        </ul>
+      </section>
+      <section>
+        <h4>Relevant Education</h4>
+        <div>
+          <header>
+            <h5>CodeSchool</h5>
+            <span class="timeperiod">2014</span>
+          </header>
+          <ul>
+            <li>Ruby</li>
+            <li>JavaScript</li>
+            <li>CoffeeScript</li>
+            <li>EmberJS</li>
+            <li>BackboneJS</li>
+          </ul>
+        </div>
+        <div>
+          <header>
+            <h5>Computing A Level at Northampton College</h5>
+            <span class="timeperiod">2008-2010</span>
+          </header>
+        </div>
+        <ul>
+          <li>Pascal</li>
+          <li>PHP</li>
+          <li>SQL</li>
+          <li>HTML</li>
+          <li>CSS</li>
+        </ul>
+      </section>
+    </main>
+  </body>
+</html>
diff --git a/static/robots.txt b/static/robots.txt
index ef30e6f..a0e9740 100644
--- a/static/robots.txt
+++ b/static/robots.txt
@@ -1,7 +1,4 @@
 User-agent: *
 Disallow:
 Host: alanpearce.eu
-Sitemap: https://alanpearce.eu/sitemap.xml
 
-User-agent: googlebot
-Disallow: /
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..eade0f9
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,38 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <h1>404</h1>
+      <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
+    </main>
+    <footer>
+      Content is
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >CC BY 4.0</a
+      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
+      <a href="https://opensource.org/licenses/MIT">MIT</a>
+    </footer>
+  </body>
+</html>
diff --git a/templates/atom.xml b/templates/atom.xml
new file mode 100644
index 0000000..81c9a76
--- /dev/null
+++ b/templates/atom.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="{{ lang }}">
+    <title>{{ config.title }}
+    {%- if term %} - {{ term.name }}
+    {%- elif section.title %} - {{ section.title }}
+    {%- endif -%}
+    </title>
+    {%- if config.description %}
+    <subtitle>{{ config.description }}</subtitle>
+    {%- endif %}
+    <link href="{{ feed_url | safe }}" rel="self" type="application/atom+xml"/>
+    <link href="
+      {%- if section -%}
+        {{ section.permalink | escape_xml | safe }}
+      {%- else -%}
+        {{ config.base_url | escape_xml | safe }}
+      {%- endif -%}
+    "/>
+    <generator uri="https://www.getzola.org/">Zola</generator>
+    <updated>{{ last_updated | date(format="%+") }}</updated>
+    <id>{{ feed_url | safe }}</id>
+    {%- for page in pages %}
+    <entry xml:lang="{{ page.lang }}">
+        <title>{{ page.title }}</title>
+        <published>{{ page.date | date(format="%+") }}</published>
+        <updated>{{ page.updated | default(value=page.date) | date(format="%+") }}</updated>
+        <author>
+          <name>
+            {%- if page.authors -%}
+              {{ page.authors[0] }}
+            {%- elif config.author -%}
+              {{ config.author }}
+            {%- else -%}
+              Unknown
+            {%- endif -%}
+          </name>
+        </author>
+        <link rel="alternate" href="{{ page.permalink | safe }}" type="text/html"/>
+        <id>{{ page.permalink | safe }}</id>
+        {% if page.summary %}
+        <summary type="html">{{ page.summary }}</summary>
+        {% else %}
+        <content type="html">{{ page.content }}</content>
+        {% endif %}
+    </entry>
+    {%- endfor %}
+</feed>
diff --git a/templates/count.html b/templates/count.html
new file mode 100644
index 0000000..737b99d
--- /dev/null
+++ b/templates/count.html
@@ -0,0 +1,6 @@
+<body>
+  <script data-goatcounter="https://alanpearce-eu.goatcounter.com/count" async src="https://gc.zgo.at/count.js"></script>
+  <noscript>
+    <img src="https://alanpearce-eu.goatcounter.com/count?p=/INSERT-PAGE-HERE" />
+  </noscript>
+</body>
diff --git a/templates/feed-styles.xsl b/templates/feed-styles.xsl
new file mode 100644
index 0000000..5953f89
--- /dev/null
+++ b/templates/feed-styles.xsl
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<xsl:stylesheet
+  version="3.0"
+  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns:atom="http://www.w3.org/2005/Atom"
+>
+  <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
+  <xsl:template match="/">
+    <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+      <head>
+        <title>RSS Feed | <xsl:value-of select="/atom:feed/atom:title"/></title>
+        <meta charset="utf-8" />
+        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <style></style>
+      </head>
+      <body>
+        <main>
+          <div class="helptext">
+            <strong>This is an RSS feed</strong>. Subscribe by copying the URL
+            from the address bar into your newsreader. Visit
+            <a href="https://aboutfeeds.com">About Feeds</a>
+            to learn more and get started. It's free.
+          </div>
+          <div>
+            <h1>
+              <!-- https://commons.wikimedia.org/wiki/File:Feed-icon.svg -->
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                version="1.1"
+                style="width: 1.5ex; height: 1.5ex"
+                viewBox="0 0 256 256"
+              >
+                <rect width="256" height="256" x="0" y="0" fill="#7F7F7F" />
+                <rect width="246" height="246" x="5" y="5" fill="#A0A0A0" />
+                <rect width="236" height="236" x="10" y="10" fill="#A6A6A6" />
+                <circle cx="68" cy="189" r="24" fill="#FFF" />
+                <path
+                  d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z"
+                  fill="#FFF"
+                />
+                <path
+                  d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z"
+                  fill="#FFF"
+                />
+              </svg>
+              RSS Feed Preview |
+              <span>
+                <xsl:value-of select="/atom:feed/atom:title" />
+              </span>
+            </h1>
+            <nav>
+              <a>
+                <xsl:attribute name="href">
+                  <xsl:value-of select="/atom:feed/atom:link[1]/@href" />
+                </xsl:attribute>
+                Visit Website
+              </a>
+            </nav>
+            <ul class="h-feed">
+              <xsl:for-each select="/atom:feed/atom:entry">
+                <li class="h-entry">
+                  <span>
+                    <time class="dt-published">
+                      <xsl:value-of select="substring(atom:updated, 0, 11)" />
+                    </time>
+                  </span>
+                  <a class="p-name u-url">
+                    <xsl:attribute name="href">
+                      <xsl:value-of select="atom:link/@href" />
+                    </xsl:attribute>
+                    <xsl:value-of select="atom:title" />
+                  </a>
+                </li>
+              </xsl:for-each>
+            </ul>
+          </div>
+        </main>
+      </body>
+    </html>
+  </xsl:template>
+</xsl:stylesheet>
diff --git a/templates/feed.xml b/templates/feed.xml
new file mode 100644
index 0000000..ddc90dd
--- /dev/null
+++ b/templates/feed.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <title>Example Feed</title>
+  <link href="http://example.org/"></link>
+  <id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
+  <updated>2003-12-13T18:30:02Z</updated>
+  <entry>
+    <title>Atom-Powered Robots Run Amok</title>
+    <link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"></link>
+    <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+    <updated>2003-12-13T18:30:02Z</updated>
+    <summary>Some text.</summary>
+    <content type="html">
+      <div>
+        <p>This is the entry content.</p>
+      </div>
+    </content>
+    <author>
+      <name>John Doe</name> 
+    </author>
+  </entry>
+
+</feed>
diff --git a/templates/homepage.html b/templates/homepage.html
new file mode 100644
index 0000000..d256e8c
--- /dev/null
+++ b/templates/homepage.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <link href="" rel="canonical" />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <div id="content"></div>
+      <section>
+        <h2>Latest Posts</h2>
+        <ul class="h-feed">
+          <li class="h-entry">
+            <span>
+              <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
+                2000-12-31
+              </time>
+            </span>
+            <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
+          </li>
+        </ul>
+      </section>
+      <section>
+        <h2>Elsewhere on the Internet</h2>
+        <ul class="elsewhere">
+          <li>
+            <a class="u-email" rel="me" href="mailto:user@example.com"
+              >user@example.com</a
+            >
+          </li>
+          <li>
+            <a class="u-url" rel="me" href="http://example.com">Example</a>
+          </li>
+        </ul>
+      </section>
+    </main>
+    <footer>
+      Content is
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >CC BY 4.0</a
+      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
+      <a href="https://opensource.org/licenses/MIT">MIT</a>
+    </footer>
+  </body>
+</html>
diff --git a/templates/list.html b/templates/list.html
new file mode 100644
index 0000000..74d6576
--- /dev/null
+++ b/templates/list.html
@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Site Title"
+      href="/atom.xml"
+    />
+    <link href="" rel="canonical" />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#content">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site Title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="content">
+      <div class="filter">
+        <h3 class="filter">Tag</h3>
+        <small>
+          <a href="../">Remove filter</a>
+        </small>
+      </div>
+      <ul class="h-feed">
+        <li class="h-entry">
+          <span>
+            <time class="dt-published" datetime="2000-12-31T12:33:02+02:00">
+              2000-12-31
+            </time>
+          </span>
+          <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
+        </li>
+      </ul>
+    </main>
+    <footer>
+      Content is
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >CC BY 4.0</a
+      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
+      <a href="https://opensource.org/licenses/MIT">MIT</a>
+    </footer>
+  </body>
+</html>
diff --git a/templates/post.html b/templates/post.html
new file mode 100644
index 0000000..7574a1f
--- /dev/null
+++ b/templates/post.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title></title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <link href="" rel="canonical" />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title"></a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <article class="h-entry">
+        <h1 class="p-name">Post Title</h1>
+        <p>
+          <time class="dt-published">2000-12-31</time>
+        </p>
+        <div class="e-content">
+          Enim lobortis scelerisque fermentum dui faucibus in ornare quam
+          viverra. Eget egestas purus viverra accumsan in nisl nisi, scelerisque
+          eu ultrices vitae, auctor eu augue ut lectus arcu, bibendum at.
+
+          <code>/bin/test</code>
+
+          <pre>
+            <code class="language-conf">
+foo=bar
+            </code>
+          </pre>
+
+          <table>
+            <thead>
+              <tr>
+                <th>One</th>
+                <th>Two</th>
+                <th>Three</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>1</td>
+                <td>2</td>
+                <td>3</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div class="tags">
+          Tags:
+          <ul class="p-categories tags">
+            <li><a class="p-category" href="/tags/sample/">#sample</a></li>
+          </ul>
+        </div>
+      </article>
+    </main>
+    <footer>
+      Content is
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >CC BY 4.0</a
+      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
+      <a href="https://opensource.org/licenses/MIT">MIT</a>
+    </footer>
+  </body>
+</html>
diff --git a/templates/style.css b/templates/style.css
new file mode 100644
index 0000000..8d21237
--- /dev/null
+++ b/templates/style.css
@@ -0,0 +1,195 @@
+body {
+  font-family: Verdana, sans-serif;
+  margin: auto;
+  padding: 1em;
+  max-width: 50rem;
+  text-align: left;
+  background-color: #fff;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  line-height: 1.5;
+  color: #444;
+}
+
+.skip {
+  position: absolute;
+  top: -3em;
+  background: #fff;
+}
+.skip:focus {
+  top: 0;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+strong,
+b {
+  color: #222;
+}
+
+a {
+  color: #3273dc;
+}
+
+.title {
+  color: #222;
+  text-decoration: none;
+  border: 0;
+}
+
+.filter {
+  margin-bottom: 0;
+}
+
+time {
+  font-style: italic;
+}
+
+nav a {
+  margin-right: 1ex;
+}
+
+.tags {
+  padding: unset;
+  font-size: small;
+}
+
+.tags ul {
+  display: inline-block;
+}
+
+.tags li {
+  list-style: none;
+  display: inline-block;
+  padding-right: 1ex;
+}
+
+textarea {
+  width: 100%;
+  font-size: 1rem;
+}
+
+input {
+  font-size: 1rem;
+}
+
+main,
+article {
+  line-height: 1.6;
+}
+
+table {
+  width: 100%;
+}
+
+img {
+  max-width: 100%;
+}
+
+code {
+  padding: 2px 5px;
+  background-color: #f2f2f2;
+}
+
+pre code {
+  color: #222;
+  display: block;
+  padding: 20px;
+  white-space: pre-wrap;
+  font-size: 0.875rem;
+  overflow-x: auto;
+}
+
+div.highlight pre {
+  background-color: initial;
+  color: initial;
+}
+
+div.highlight code {
+  background-color: unset;
+  color: unset;
+}
+
+blockquote {
+  border-left: 1px solid #999;
+  color: #222;
+  padding-left: 20px;
+  font-style: italic;
+}
+
+footer {
+  padding: 25px;
+  text-align: center;
+}
+
+.helptext {
+  color: #777;
+  font-size: small;
+}
+
+/* blog posts */
+ul.h-feed {
+  list-style-type: none;
+  padding: unset;
+}
+
+ul.h-feed li {
+  display: flex;
+}
+
+ul.h-feed li span {
+  flex: 0 0 130px;
+}
+
+ul.h-feed li a:visited {
+  color: #8b6fcb;
+}
+
+@media (prefers-color-scheme: dark) {
+  body {
+    background-color: #333;
+    color: #ddd;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  strong,
+  b,
+  .title {
+    color: #eee;
+  }
+
+  a {
+    color: #8cc2dd;
+  }
+
+  code {
+    background-color: #777;
+  }
+
+  pre code {
+    color: #ddd;
+  }
+
+  blockquote {
+    color: #ccc;
+  }
+
+  textarea,
+  input {
+    background-color: #252525;
+    color: #ddd;
+  }
+
+  .helptext {
+    color: #aaa;
+  }
+}
diff --git a/templates/tags.html b/templates/tags.html
new file mode 100644
index 0000000..79c1c09
--- /dev/null
+++ b/templates/tags.html
@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Site title"
+      href="/atom.xml"
+    />
+    <link href="" rel="canonical" />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#content">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="content">
+      <h3 class="filter">Tags</h3>
+      <ul class="tags">
+        <li class="h-feed">
+          <a href="/tags/tag">#tag</a>
+        </li>
+      </ul>
+    </main>
+    <footer>
+      Content is
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >CC BY 4.0</a
+      >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is
+      <a href="https://opensource.org/licenses/MIT">MIT</a>
+    </footer>
+  </body>
+</html>
diff --git a/themes/xmin/static/css/style.css b/themes/xmin/static/css/style.css
deleted file mode 100644
index e605297..0000000
--- a/themes/xmin/static/css/style.css
+++ /dev/null
@@ -1,75 +0,0 @@
-body {
-  font-family: sans-serif;
-  line-height: 1.5em;
-  margin: auto;
-  max-width: 800px;
-  padding: 1em;
-}
-
-/* header and footer areas */
-nav > ul { padding: 0; }
-nav > ul > li { display: inline-block; }
-article > header, nav > ul a {
-  background: #eee;
-  border-radius: 5px;
-  padding: 5px;
-  text-decoration: none;
-}
-.terms { font-size: .9em; }
-nav > ul, article > header, footer { text-align: center; }
-.title { font-size: 1.1em; }
-footer a { text-decoration: none; }
-hr {
-  border-style: dashed;
-  color: #ddd;
-}
-body > nav {
-  border-bottom: 1px solid #ddd;
-}
-body > footer {
-  border-top: 1px solid #ddd;
-}
-
-/* code */
-pre {
-  border: 1px solid #ddd;
-  overflow-x: auto;
-  padding: 1em;
-}
-code { background: #f9f9f9; }
-pre code { background: none; }
-
-/* misc elements */
-img, iframe, video { max-width: 100%; }
-main { hyphens: auto; }
-blockquote {
-  background: #f9f9f9;
-  border-left: 5px solid #ccc;
-  padding: 3px 1em 3px;
-}
-
-table thead th { border-bottom: 1px solid #ddd; }
-th, td { padding: 5px; }
-thead, tfoot, tr:nth-child(even) { background: #eee; }
-.hl { background-color: #ffc; }
-
-@media (prefers-color-scheme: dark) {
-	body {
-		background-color: #111;
-		color: white;
-	}
-	article > header, nav > ul a {
-		background: #222;
-	}
-	a {
-		color: #C4D4EE;
-	}
-	a:visited {
-		color: #CEDEE0;
-	}
-	code { background-color: #444; }
-	thead, tfoot, tr:nth-child(even) { background: #222; }
-	.hl {
-		background-color: #555;
-	}
-}
diff --git a/themes/xmin/templates/base.html b/themes/xmin/templates/base.html
deleted file mode 100644
index 5942342..0000000
--- a/themes/xmin/templates/base.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html lang="{{ lang }}">
-  <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title {%- if current_path == '/' %} class="p-name"{% endif %}>{% block title %}{{ section.title }} | {{ config.title }}{% endblock %}</title>
-    <link rel="stylesheet" href="/css/style.css" />
-    {%- if config.generate_feed %}
-    {%- block rss %}
-    <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="RSS" href="{{ get_url(path=config.feed_filename) | safe }}">
-    {%- endblock %}
-    {%- endif %}
-  </head>
-  <body>
-    <nav>
-      <ul>
-        {%- for item in config.extra.menu.main %}
-          <li><a {%- if item.url == "/" %} class="author"{% endif %} href="{{ item.url | safe }}">{{ item.name }}</a></li>
-        {%- endfor %}
-      </ul>
-    </nav>
-    {% block main %}{% endblock %}
-    <footer>{{ config.extra.footer | safe }}</footer>
-  </body>
-</html>
diff --git a/themes/xmin/templates/categories/list.html b/themes/xmin/templates/categories/list.html
deleted file mode 120000
index e0e4e08..0000000
--- a/themes/xmin/templates/categories/list.html
+++ /dev/null
@@ -1 +0,0 @@
-../tags/list.html
\ No newline at end of file
diff --git a/themes/xmin/templates/categories/single.html b/themes/xmin/templates/categories/single.html
deleted file mode 120000
index 86f5e80..0000000
--- a/themes/xmin/templates/categories/single.html
+++ /dev/null
@@ -1 +0,0 @@
-../tags/single.html
\ No newline at end of file
diff --git a/themes/xmin/templates/index.html b/themes/xmin/templates/index.html
deleted file mode 100644
index 23ec4cd..0000000
--- a/themes/xmin/templates/index.html
+++ /dev/null
@@ -1,36 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-<main class="h-card">
-  <h1 class="p-name">{{ config.title }}</h1>
-  {{ section.content | safe }}
-  <section>
-    <h2>Latest Posts</h2>
-    <ul class="h-feed">
-      {%- for page in section.pages | slice(end=3) %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-  <section>
-    <h2>Elsewhere on the Internet</h2>
-    <ul>
-      {%- for item in config.extra.menu.contact %}
-        <li>
-          {%- if item.url is starting_with("mailto:") %}
-            <a href="{{ item.url | safe }}" class="u-email email" rel="me">{{ item.name }}</a>
-          {%- else %}
-            <a href="{{ item.url | safe }}" class="u-url url" rel="me">{{ item.name }}</a>
-          {%- endif %}
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-  <footer>
-    GPG Key: <a href="{{ config.extra.gpg_url | safe }}" rel="u-key pgpkey">{{ config.extra.gpg_fingerprint }}</a>
-  </footer>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/page.html b/themes/xmin/templates/page.html
deleted file mode 100644
index f32a6fc..0000000
--- a/themes/xmin/templates/page.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-{{- page.title -}}
-{% endblock %}
-
-{% block main %}
-<article class="h-entry">
-  <header>
-    <h1><span class="title p-name">{{ page.title }}</span></h1>
-    <time class="dt-published" datetime="{{ page.date | date(format='%+') }}">{{ page.date | date(format="%F") }}</time>
-    <p class="terms">
-      {%- if page.taxonomies %}
-      {%- for name, taxon in page.taxonomies %}
-      {{ name | capitalize }}:
-      {%- for item in taxon %}
-      <a class="p-category" href="{{ get_taxonomy_url(kind=name, name=item) }}">{{ item }}</a>
-      {%- endfor %}
-      {%- endfor %}
-      {%- endif %}
-    </p>
-  </header>
-
-  <main class="e-content">
-    {{ page.content | safe }}
-  </main>
-</article>
-{% endblock %}
diff --git a/themes/xmin/templates/section.html b/themes/xmin/templates/section.html
deleted file mode 100644
index e61566f..0000000
--- a/themes/xmin/templates/section.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-<main>
-  <h1>{{ section.title }}</h1>
-  {{ section.content }}
-  <section>
-    <ul>
-      {% for page in section.pages %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a>
-        </li>
-      {% endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/tags/list.html b/themes/xmin/templates/tags/list.html
deleted file mode 100644
index ee60c39..0000000
--- a/themes/xmin/templates/tags/list.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ taxonomy.name | capitalize }}{% endblock %}
-
-{% block main %}
-<main>
-  <h1>{{ taxonomy.name | capitalize }}</h1>
-  <section>
-    <ul>
-      {%- for term in terms %}
-        <li>
-          <a href="{{ term.permalink }}">{{ term.name }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/templates/tags/single.html b/themes/xmin/templates/tags/single.html
deleted file mode 100644
index 25dde54..0000000
--- a/themes/xmin/templates/tags/single.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends "base.html" %}
-
-{% block rss %}
-  {% set rss_path = "tags/" ~ term.name ~ "/atom.xml" %}
-  <link rel="alternate" type="application/atom+xml" title="RSS" href="{{ get_url(path=rss_path, trailing_slash=false) | safe }}">
-
-{% endblock %}
-
-{% block title %}{{ taxonomy.name | capitalize }}: {{ term.name }} | {{ config.title }}{% endblock %}
-
-{% block main %}
-<main>
-  <h1>{{ taxonomy.name | capitalize }}: {{ term.name }}</h1>
-  <section>
-    <ul class="h-feed">
-      {%- for page in term.pages %}
-        <li class="h-entry">
-          <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time>
-          <a class="u-url p-name" href="{{ page.permalink | safe }}">{{ page.title }}</a>
-        </li>
-      {%- endfor %}
-    </ul>
-  </section>
-</main>
-{% endblock %}
diff --git a/themes/xmin/theme.toml b/themes/xmin/theme.toml
deleted file mode 100644
index 99884b9..0000000
--- a/themes/xmin/theme.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-name = "xmin"
-description = "XMin is a Hugo theme written by Yihui Xie in about four hours"
-license = "MIT"
-
-[author]
-name = "Alan Pearce"
-homepage = "https://www.alanpearce.eu"
-
-[original]
-author = "yihui"
-homepage = "https://yihui.org"
-repo = "https://github.com/yihui/hugo-xmin"
\ No newline at end of file