about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAlan Pearce2024-06-18 16:46:22 +0200
committerAlan Pearce2024-06-18 16:46:22 +0200
commit1d247493e05cdc659e46cd3d8a01d5da1e893867 (patch)
tree221e9ee2f5e3f171dfd937f04fae7ad6a33588d8
parenta238c7e0889cbe7dfaa1a700dea30686a4e2139a (diff)
downloadwebsite-1d247493e05cdc659e46cd3d8a01d5da1e893867.tar.lz
website-1d247493e05cdc659e46cd3d8a01d5da1e893867.tar.zst
website-1d247493e05cdc659e46cd3d8a01d5da1e893867.zip
switch to templ for rendering HTML templates
-rw-r--r--.gitignore3
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/builder/404.templ13
-rw-r--r--internal/builder/builder.go62
-rw-r--r--internal/builder/homepage.templ54
-rw-r--r--internal/builder/list.templ48
-rw-r--r--internal/builder/page.templ84
-rw-r--r--internal/builder/post.templ50
-rw-r--r--internal/builder/tags.templ23
-rw-r--r--internal/builder/template.go265
-rw-r--r--modd.conf4
-rw-r--r--nix/gomod2nix.toml3
-rw-r--r--shell.nix2
-rw-r--r--templates/404.html37
-rw-r--r--templates/count.html8
-rw-r--r--templates/dev.html8
-rw-r--r--templates/homepage.html63
-rw-r--r--templates/list.html52
-rw-r--r--templates/post.html78
-rw-r--r--templates/style.css1
-rw-r--r--templates/tags.html42
22 files changed, 324 insertions, 579 deletions
diff --git a/.gitignore b/.gitignore
index 88424a9..e40b491 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@ go.work
 /result
 .vercel
 /.netlify/
+
+*_templ.go
+*_templ.txt
diff --git a/go.mod b/go.mod
index 5495a00..5660e67 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/BurntSushi/toml v1.3.2
 	github.com/PuerkitoBio/goquery v1.9.2
 	github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e
+	github.com/a-h/templ v0.2.707
 	github.com/adrg/frontmatter v0.2.0
 	github.com/antchfx/xmlquery v1.4.0
 	github.com/antchfx/xpath v1.3.0
diff --git a/go.sum b/go.sum
index c65524b..398c65f 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,8 @@ github.com/Code-Hex/dd v1.1.0 h1:VEtTThnS9l7WhpKUIpdcWaf0B8Vp0LeeSEsxA1DZseI=
 github.com/Code-Hex/dd v1.1.0/go.mod h1:VaMyo/YjTJ3d4qm/bgtrUkT2w+aYwJ07Y7eCWyrJr1w=
 github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
+github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
 github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4=
 github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE=
 github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562 h1:7LpBXZnmFk8+RwdFnAYB7rKZhBQrQ4poPLEhpwwbmSc=
diff --git a/internal/builder/404.templ b/internal/builder/404.templ
new file mode 100644
index 0000000..049e67d
--- /dev/null
+++ b/internal/builder/404.templ
@@ -0,0 +1,13 @@
+package builder
+
+import "website/internal/config"
+
+templ notFound(config config.Config, path string) {
+	@page(config, PageSettings{
+		Title: "Not Found",
+		Path:  path,
+	}) {
+		<h1>404</h1>
+		<h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
+	}
+}
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index bb6f40d..7b6af0b 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -1,18 +1,20 @@
 package builder
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/url"
 	"os"
 	"path"
 	"slices"
-	"sync"
 	"time"
 
 	"website/internal/config"
 	"website/internal/log"
 
+	"github.com/a-h/templ"
+	mapset "github.com/deckarep/golang-set/v2"
 	cp "github.com/otiai10/copy"
 	"github.com/pkg/errors"
 	"github.com/snabb/sitemap"
@@ -36,7 +38,7 @@ func mkdirp(dirs ...string) error {
 }
 
 func outputToFile(output io.Reader, filename ...string) error {
-	log.Debug("outputting file", "filename", path.Join(filename...))
+	// log.Debug("outputting file", "filename", path.Join(filename...))
 	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
@@ -50,8 +52,23 @@ func outputToFile(output io.Reader, filename ...string) error {
 	return nil
 }
 
+func renderToFile(component templ.Component, filename ...string) error {
+	// log.Debug("outputting file", "filename", path.Join(filename...))
+	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if err := component.Render(context.TODO(), file); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
 func writerToFile(writer io.WriterTo, filename ...string) error {
-	log.Debug("outputting file", "filename", path.Join(filename...))
+	// log.Debug("outputting file", "filename", path.Join(filename...))
 	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
@@ -70,7 +87,6 @@ func build(outDir string, config config.Config) (*Result, error) {
 	r := &Result{
 		Hashes: make([]string, 0),
 	}
-	assetsOnce = sync.Once{}
 	privateDir := path.Join(outDir, "private")
 	if err := mkdirp(privateDir); err != nil {
 		return nil, errors.WithMessage(err, "could not create private directory")
@@ -112,11 +128,7 @@ func build(outDir string, config config.Config) (*Result, error) {
 			Loc:     post.URL,
 			LastMod: &post.Date,
 		})
-		output, err := renderPost(post, config)
-		if err != nil {
-			return nil, errors.WithMessagef(err, "could not render post %s", post.Input)
-		}
-		if err := outputToFile(output, post.Output); err != nil {
+		if err := renderToFile(postPage(config, post), post.Output); err != nil {
 			return nil, err
 		}
 	}
@@ -125,11 +137,7 @@ func build(outDir string, config config.Config) (*Result, error) {
 		return nil, errors.WithMessage(err, "could not create directory for tags")
 	}
 	log.Debug("rendering tags list")
-	output, err := renderTags(tags, config, "/tags")
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not render tags")
-	}
-	if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil {
+	if err := renderToFile(tagsPage(config, "tags", mapset.Sorted(tags), "/tags"), publicDir, "tags", "index.html"); err != nil {
 		return nil, err
 	}
 	sm.Add(&sitemap.URL{
@@ -149,11 +157,7 @@ func build(outDir string, config config.Config) (*Result, error) {
 		}
 		log.Debug("rendering tags page", "tag", tag)
 		url := "/tags/" + tag
-		output, err := renderListPage(tag, config, matchingPosts, url)
-		if err != nil {
-			return nil, errors.WithMessage(err, "could not render tag page")
-		}
-		if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil {
+		if err := renderToFile(tagPage(config, tag, matchingPosts, url), publicDir, "tags", tag, "index.html"); err != nil {
 			return nil, err
 		}
 		sm.Add(&sitemap.URL{
@@ -177,11 +181,7 @@ func build(outDir string, config config.Config) (*Result, error) {
 	}
 
 	log.Debug("rendering list page")
-	listPage, err := renderListPage("", config, posts, "/post")
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not render list page")
-	}
-	if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil {
+	if err := renderToFile(listPage(config, posts, "/post"), publicDir, "post", "index.html"); err != nil {
 		return nil, err
 	}
 	sm.Add(&sitemap.URL{
@@ -217,11 +217,7 @@ func build(outDir string, config config.Config) (*Result, error) {
 	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering homepage")
-	homePage, err := renderHomepage(config, posts, "/")
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not render homepage")
-	}
-	if err := outputToFile(homePage, publicDir, "index.html"); err != nil {
+	if err := renderToFile(homepage(config, posts), publicDir, "index.html"); err != nil {
 		return nil, err
 	}
 	// it would be nice to set LastMod here, but using the latest post
@@ -230,15 +226,11 @@ func build(outDir string, config config.Config) (*Result, error) {
 	sm.Add(&sitemap.URL{
 		Loc: "/",
 	})
-	h, err = getHTMLStyleHash(publicDir, "index.html")
+	h, _ = getHTMLStyleHash(publicDir, "index.html")
 	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering 404 page")
-	notFound, err := render404(config, "/404.html")
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not render 404 page")
-	}
-	if err := outputToFile(notFound, publicDir, "404.html"); err != nil {
+	if err := renderToFile(notFound(config, "/404.html"), publicDir, "404.html"); err != nil {
 		return nil, err
 	}
 
diff --git a/internal/builder/homepage.templ b/internal/builder/homepage.templ
new file mode 100644
index 0000000..9897b5d
--- /dev/null
+++ b/internal/builder/homepage.templ
@@ -0,0 +1,54 @@
+package builder
+
+import (
+	"website/internal/config"
+	"path"
+)
+
+func getContent(filename string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+		_, index, err := getPost(path.Join("content", filename))
+		if err != nil {
+			return err
+		}
+		_, err = io.WriteString(w, string(index))
+
+		return err
+	})
+}
+
+templ homepage(config config.Config, posts []Post) {
+	@page(config, PageSettings{
+		Title: config.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-name u-url",
+		},
+		Path: "/",
+		BodyAttrs: templ.Attributes{
+			"class": "h-card",
+		},
+	}) {
+		<div id="content">
+			@getContent("_index.md")
+		</div>
+		<section>
+			<h2>Latest Posts</h2>
+			@list(posts[0:3])
+		</section>
+		<section>
+			<h2>Elsewhere on the Internet</h2>
+			<ul class="elsewhere">
+				<li>
+					<a class="u-email" rel="me" href={ templ.SafeURL("mailto:" + config.Email) }>
+						{ config.Email }
+					</a>
+				</li>
+				for _, link := range config.Menus["me"] {
+					<li>
+						<a class="u-url" rel="me" href={ templ.SafeURL(link.URL) }>{ link.Name }</a>
+					</li>
+				}
+			</ul>
+		</section>
+	}
+}
diff --git a/internal/builder/list.templ b/internal/builder/list.templ
new file mode 100644
index 0000000..48563ed
--- /dev/null
+++ b/internal/builder/list.templ
@@ -0,0 +1,48 @@
+package builder
+
+import "website/internal/config"
+
+templ tagPage(config config.Config, tag string, posts []Post, path string) {
+	@page(config, PageSettings{
+		Title: tag,
+		Path:  path,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+	}) {
+		<div class="filter">
+			<h3 class="filter">#{ tag }</h3>
+			<small>
+				<a href="../">Remove filter</a>
+			</small>
+		</div>
+		@list(posts)
+	}
+}
+
+templ listPage(config config.Config, posts []Post, path string) {
+	@page(config, PageSettings{
+		Title: config.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+		Path: path,
+	}) {
+		@list(posts)
+	}
+}
+
+templ list(posts []Post) {
+	<ul class="h-feed">
+		for _, post := range posts {
+			<li class="h-entry">
+				<span>
+					@postDate(post.Date)
+				</span>
+				<a class="p-name u-url" href={ templ.SafeURL(post.URL) }>{ post.Title }</a>
+			</li>
+		}
+	</ul>
+}
diff --git a/internal/builder/page.templ b/internal/builder/page.templ
new file mode 100644
index 0000000..c99e315
--- /dev/null
+++ b/internal/builder/page.templ
@@ -0,0 +1,84 @@
+package builder
+
+import (
+	"net/url"
+	"website/internal/config"
+)
+
+type PageSettings struct {
+	Title      string
+	Path       string
+	TitleAttrs templ.Attributes
+	BodyAttrs  templ.Attributes
+}
+
+func extendClasses(cs string, attrs templ.Attributes) string {
+	if extras, exists := attrs["class"]; exists {
+		return templ.Classes(cs, extras).String()
+	} else {
+		return cs
+	}
+}
+
+templ page(site config.Config, page PageSettings) {
+	<!DOCTYPE html>
+	<html lang={ site.DefaultLanguage }>
+		<head>
+			<meta charset="utf-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+			<title>{ page.Title }</title>
+			<link rel="alternate" type="application/atom+xml" title={ site.Title } href="/atom.xml"/>
+			@style(css)
+		</head>
+		<body { page.BodyAttrs... }>
+			<a class="skip" href="#main">Skip to main content</a>
+			<header>
+				<h2>
+					<a href="/" class={ extendClasses("title p-name", page.TitleAttrs) } { page.TitleAttrs... }>{ site.Title }</a>
+				</h2>
+				<nav>
+					for _, item := range site.Menus["main"] {
+						<a href={ templ.SafeURL(item.URL) }>{ item.Name }</a>
+					}
+				</nav>
+			</header>
+			<main id="main">
+				{ children... }
+			</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>
+			@counter(page.Path, page.Title)
+		</body>
+	</html>
+}
+
+func mkURL(path string, title string) string {
+	u, err := url.Parse("https://alanpearce-eu.goatcounter.com/count")
+	if err != nil {
+		panic(err)
+	}
+	q := u.Query()
+	q.Add("p", path)
+	q.Add("t", title)
+	u.RawQuery = q.Encode()
+
+	return u.String()
+}
+
+templ counter(path string, title string) {
+	<script data-goatcounter="https://alanpearce-eu.goatcounter.com/count" async src="https://gc.zgo.at/count.v4.js" crossorigin="anonymous" integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script>
+	<noscript>
+		<img src={ string(templ.URL(mkURL(path, title))) }/>
+	</noscript>
+}
+
+func style(css string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+		_, err = io.WriteString(w, "<style>\n"+css+"\n</style>")
+		return
+	})
+}
diff --git a/internal/builder/post.templ b/internal/builder/post.templ
new file mode 100644
index 0000000..740c5aa
--- /dev/null
+++ b/internal/builder/post.templ
@@ -0,0 +1,50 @@
+package builder
+
+import (
+	"time"
+	"website/internal/config"
+)
+
+func Unsafe(html string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+		_, err = io.WriteString(w, html)
+		return
+	})
+}
+
+templ postDate(d time.Time) {
+	<time class="dt-published" datetime={ d.UTC().Format(time.RFC3339) }>
+		{ d.Format("2006-01-02") }
+	</time>
+}
+
+templ postPage(config config.Config, post Post) {
+	@page(config, PageSettings{
+		Title: post.Title,
+		TitleAttrs: templ.Attributes{
+			"class": "p-author h-card",
+			"rel":   "author",
+		},
+		Path: post.URL,
+	}) {
+		<article class="h-entry">
+			<h1 class="p-name">{ post.Title }</h1>
+			<p>
+				@postDate(post.Date)
+			</p>
+			<div class="e-content">
+				@Unsafe(post.Content)
+			</div>
+			<div class="tags">
+				Tags:
+				<ul class="p-categories tags">
+					for _, tag := range post.Taxonomies.Tags {
+						<li>
+							@tagLink(tag, templ.Attributes{"class": "p-category"})
+						</li>
+					}
+				</ul>
+			</div>
+		</article>
+	}
+}
diff --git a/internal/builder/tags.templ b/internal/builder/tags.templ
new file mode 100644
index 0000000..14abca4
--- /dev/null
+++ b/internal/builder/tags.templ
@@ -0,0 +1,23 @@
+package builder
+
+import "website/internal/config"
+
+templ tagLink(tag string, attrs templ.Attributes) {
+	<a { attrs... } href={ templ.SafeURL("/tags/" + tag) }>#{ tag }</a>
+}
+
+templ tagsPage(config config.Config, title string, tags []string, path string) {
+	@page(config, PageSettings{
+		Title: title,
+		Path:  path,
+	}) {
+		<h3 class="filter">Tags</h3>
+		<ul class="tags">
+			for _, tag := range tags {
+				<li class="h-feed">
+					@tagLink(tag, templ.Attributes{})
+				</li>
+			}
+		</ul>
+	}
+}
diff --git a/internal/builder/template.go b/internal/builder/template.go
index bc31ad1..376e48a 100644
--- a/internal/builder/template.go
+++ b/internal/builder/template.go
@@ -2,15 +2,11 @@ package builder
 
 import (
 	"encoding/xml"
-	"fmt"
 	"io"
-	"net/url"
 	"os"
 	"path/filepath"
 	"strings"
-	"sync"
 	"text/template"
-	"time"
 	"website/internal/atom"
 	"website/internal/config"
 	"website/internal/log"
@@ -19,24 +15,28 @@ import (
 	"github.com/a-h/htmlformat"
 	"github.com/antchfx/xmlquery"
 	"github.com/antchfx/xpath"
-	mapset "github.com/deckarep/golang-set/v2"
 	"github.com/pkg/errors"
 	"golang.org/x/net/html"
 )
 
 var (
-	assetsOnce     sync.Once
-	css            string
-	countHTML      *goquery.Document
-	liveReloadHTML *goquery.Document
-	templates      = make(map[string]*os.File)
-	nsMap          = map[string]string{
+	css       string
+	templates = make(map[string]*os.File)
+	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",
 	}
 )
 
+func init() {
+	bytes, err := os.ReadFile("templates/style.css")
+	if err != nil {
+		panic(err)
+	}
+	css = string(bytes)
+}
+
 func loadTemplate(path string) (file *os.File, err error) {
 	if templates[path] == nil {
 		file, err = os.OpenFile(path, os.O_RDONLY, 0)
@@ -50,12 +50,6 @@ func loadTemplate(path string) (file *os.File, err error) {
 	return
 }
 
-var (
-	imgOnce     sync.Once
-	img         *goquery.Selection
-	urlTemplate *url.URL
-)
-
 type QuerySelection struct {
 	*goquery.Selection
 }
@@ -74,234 +68,6 @@ 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), errors.Wrap(err, "could not parse HTML")
-}
-
-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 func() {
-		_, err := html.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset template file offset: " + err.Error())
-		}
-	}()
-	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
-		}
-		if config.InjectLiveReload {
-			liveReloadFile, err := os.OpenFile("templates/dev.html", os.O_RDONLY, 0)
-			if err != nil {
-				return
-			}
-			defer liveReloadFile.Close()
-			liveReloadHTML, err = goquery.NewDocumentFromReader(liveReloadFile)
-			if err != nil {
-				return
-			}
-		}
-	})
-	if err != nil {
-		return nil, errors.Wrap(err, "could not set up layout template")
-	}
-
-	doc, err := NewDocumentFromReader(html)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("html").SetAttr("lang", config.DefaultLanguage)
-	doc.Find("head > link[rel=alternate]").SetAttr("title", config.Title)
-	doc.Find("head > link[rel=canonical]").SetAttr("href", pageURL)
-	doc.Find(".title").SetText(config.Title)
-	doc.Find("title").Add(".p-name").SetText(pageTitle)
-	doc.Find("head > style").SetHtml(css)
-	doc.Find("body").setImgURL(pageURL, pageTitle)
-	if config.InjectLiveReload {
-		doc.Find("body").AppendSelection(liveReloadHTML.Find("body").Clone())
-	}
-	nav := doc.Find("nav")
-	navLink := doc.Find("nav a")
-	nav.Empty()
-	for _, link := range config.Menus["main"] {
-		nav.AppendSelection(navLink.Clone().SetAttr("href", link.URL).SetText(link.Name))
-	}
-
-	return doc.Document, nil
-}
-
-func renderPost(post Post, config config.Config) (r io.Reader, err error) {
-	doc, err := layout("templates/post.html", config, post.PostMatter.Title, post.URL)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author")
-	doc.Find(".h-entry .dt-published").
-		SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)).
-		SetText(
-			post.PostMatter.Date.Format("2006-01-02"),
-		)
-	doc.Find(".h-entry .e-content").SetHtml(post.Content)
-	categories := doc.Find(".h-entry .p-categories")
-	tpl := categories.Find(".p-category").ParentsUntilSelection(categories)
-	tpl.Remove()
-	for _, tag := range post.Taxonomies.Tags {
-		cat := tpl.Clone()
-		cat.Find(".p-category").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag)
-		categories.AppendSelection(cat)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderTags(tags Tags, config config.Config, url string) (io.Reader, error) {
-	doc, err := layout("templates/tags.html", config, config.Title, url)
-	if err != nil {
-		return nil, err
-	}
-	tagList := doc.Find(".tags")
-	tpl := doc.Find(".h-feed")
-	tpl.Remove()
-	for _, tag := range mapset.Sorted(tags) {
-		li := tpl.Clone()
-		li.Find("a").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag)
-		tagList.AppendSelection(li)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderListPage(tag string, config config.Config, posts []Post, url string) (io.Reader, error) {
-	var title string
-	if len(tag) > 0 {
-		title = tag
-	} else {
-		title = config.Title
-	}
-	doc, err := layout("templates/list.html", config, title, url)
-	if err != nil {
-		return nil, err
-	}
-	feed := doc.Find(".h-feed")
-	tpl := feed.Find(".h-entry")
-	tpl.Remove()
-
-	doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author")
-	if tag == "" {
-		doc.Find(".filter").Remove()
-	} else {
-		doc.Find(".filter").Find("h3").SetText("#" + tag)
-	}
-
-	for _, post := range posts {
-		entry := tpl.Clone()
-		entry.Find(".p-name").SetText(post.Title).SetAttr("href", post.URL)
-		entry.Find(".dt-published").
-			SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)).
-			SetText(post.PostMatter.Date.Format("2006-01-02"))
-		feed.AppendSelection(entry)
-	}
-
-	return renderHTML(doc), nil
-}
-
-func renderHomepage(config config.Config, posts []Post, url string) (io.Reader, error) {
-	_, index, err := getPost("content/_index.md")
-	if err != nil {
-		return nil, err
-	}
-	doc, err := layout("templates/homepage.html", config, config.Title, url)
-	if err != nil {
-		return nil, err
-	}
-	doc.Find("body").AddClass("h-card")
-	doc.Find(".title").AddClass("p-name u-url")
-
-	html, err := renderMarkdown(index)
-	if err != nil {
-		return nil, err
-	}
-	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 renderRobotsTXT(config config.Config) (io.Reader, error) {
 	r, w := io.Pipe()
 	tpl, err := template.ParseFiles("templates/robots.tmpl")
@@ -320,15 +86,6 @@ func renderRobotsTXT(config config.Config) (io.Reader, error) {
 	return r, 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,
diff --git a/modd.conf b/modd.conf
index 1c46a59..072956c 100644
--- a/modd.conf
+++ b/modd.conf
@@ -1,3 +1,3 @@
-config.toml cmd/server/* "internal/**" {
-    daemon: go run ./cmd/server --dev
+config.toml {
+    daemon: templ generate --watch --proxy "http://localhost:3000" --cmd "go run ./cmd/server --dev" --open-browser=false
 }
diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml
index 089a876..0bc3fc9 100644
--- a/nix/gomod2nix.toml
+++ b/nix/gomod2nix.toml
@@ -14,6 +14,9 @@ schema = 3
     version = "v0.0.0-20240425000139-1244374b2562"
     hash = "sha256-qvnbf/VCR2s2VmyPaQeHLkpA01MNy1g1U0l9B9maBcE="
     replaced = "github.com/alanpearce/htmlformat"
+  [mod."github.com/a-h/templ"]
+    version = "v0.2.707"
+    hash = "sha256-UoM2qj8E7C4NBAMhS/2jrOw0Dj/gnsyZRL4NpRCWaMo="
   [mod."github.com/adrg/frontmatter"]
     version = "v0.2.0"
     hash = "sha256-WJsVcdCpkIkjqUz5fJOFStZYwQlrcFzQ6+mZatZiimo="
diff --git a/shell.nix b/shell.nix
index 17cd5ce..3b81469 100644
--- a/shell.nix
+++ b/shell.nix
@@ -17,11 +17,13 @@ pkgs.mkShell {
   inherit (pre-commit-check) shellHook;
   packages = with pkgs; [
     goEnv
+    gomod2nix
 
     npins
     gopls
     gotools
     go-tools
+    templ
     gci
     hyperlink
     systemfd
diff --git a/templates/404.html b/templates/404.html
deleted file mode 100644
index 81b2a54..0000000
--- a/templates/404.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <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/count.html b/templates/count.html
deleted file mode 100644
index 46d5ac4..0000000
--- a/templates/count.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<body>
-  <script data-goatcounter="https://alanpearce-eu.goatcounter.com/count"
-        async src="https://gc.zgo.at/count.v4.js" crossorigin="anonymous"
-        integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script>
-  <noscript>
-    <img src="https://alanpearce-eu.goatcounter.com/count?p=/updated-in-template.go" />
-  </noscript>
-</body>
diff --git a/templates/dev.html b/templates/dev.html
deleted file mode 100644
index 0ca383e..0000000
--- a/templates/dev.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<body>
-  <script defer>
-    new EventSource("/_/reload").onmessage = event => {
-      console.log("got message", event)
-      window.location.reload()
-    };
-  </script>
-</body>
diff --git a/templates/homepage.html b/templates/homepage.html
deleted file mode 100644
index 60bedb8..0000000
--- a/templates/homepage.html
+++ /dev/null
@@ -1,63 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Site Title</title>
-    <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
deleted file mode 100644
index 1c0b32b..0000000
--- a/templates/list.html
+++ /dev/null
@@ -1,52 +0,0 @@
-<!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>
-    <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
deleted file mode 100644
index 3dad16c..0000000
--- a/templates/post.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<!doctype html>
-<html lang="en-GB">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title></title>
-    <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
index b386843..84ce1ce 100644
--- a/templates/style.css
+++ b/templates/style.css
@@ -198,6 +198,7 @@ ul.tags {
 .tags li {
   list-style: none;
   display: inline-block;
+  margin-inline-end: 1ex;
 }
 
 svg.rss-icon {
diff --git a/templates/tags.html b/templates/tags.html
deleted file mode 100644
index 1ff18c0..0000000
--- a/templates/tags.html
+++ /dev/null
@@ -1,42 +0,0 @@
-<!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>
-    <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>