about summary refs log tree commit diff stats
path: root/internal/builder
diff options
context:
space:
mode:
Diffstat (limited to 'internal/builder')
-rw-r--r--internal/builder/builder.go277
-rw-r--r--internal/builder/files.go120
-rw-r--r--internal/builder/hasher.go13
-rw-r--r--internal/builder/posts.go129
-rw-r--r--internal/builder/sitemap.go29
-rw-r--r--internal/builder/template.go437
6 files changed, 355 insertions, 650 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index b17fbc2..b99d919 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -1,54 +1,80 @@
 package builder
 
 import (
+	"context"
 	"fmt"
 	"io"
-	"net/url"
 	"os"
 	"path"
+	"path/filepath"
 	"slices"
-	"sync"
 	"time"
 
-	"website/internal/config"
-	"website/internal/log"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+	"go.alanpearce.eu/x/log"
+	"go.alanpearce.eu/website/internal/sitemap"
+	"go.alanpearce.eu/website/templates"
 
-	cp "github.com/otiai10/copy"
-	"github.com/pkg/errors"
-	"github.com/snabb/sitemap"
+	"github.com/a-h/templ"
+	mapset "github.com/deckarep/golang-set/v2"
+	"gitlab.com/tozd/go/errors"
 )
 
 type IOConfig struct {
 	Source      string `conf:"default:.,short:s,flag:src"`
-	Destination string `conf:"default:website,short:d,flag:dest"`
-	BaseURL     config.URL
-	Development bool `conf:"default:false,flag:dev"`
+	Destination string `conf:"default:public,short:d,flag:dest"`
+	Development bool   `conf:"default:false,flag:dev"`
 }
 
+type Result struct {
+	Hashes []string
+}
+
+var compressFiles = false
+
 func mkdirp(dirs ...string) error {
 	err := os.MkdirAll(path.Join(dirs...), 0755)
 
 	return errors.Wrap(err, "could not create directory")
 }
 
-func outputToFile(output io.Reader, 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)
+func outputToFile(output io.Reader, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", filename)
+	file, err := openFileAndVariants(filename)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
 	}
 	defer file.Close()
 
-	if _, err := file.ReadFrom(output); err != nil {
+	if _, err := io.Copy(file, output); 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...))
-	file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+func renderToFile(component templ.Component, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", filename)
+	file, err := openFileAndVariants(filename)
+	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, pathParts ...string) error {
+	filename := path.Join(pathParts...)
+	// log.Debug("outputting file", "filename", path.Join(filename...))
+	file, err := openFileAndVariants(filename)
 	if err != nil {
 		return errors.WithMessage(err, "could not open output file")
 	}
@@ -61,206 +87,185 @@ func writerToFile(writer io.WriterTo, filename ...string) error {
 	return nil
 }
 
-func build(outDir string, config config.Config) error {
-	log.Debug("output", "dir", outDir)
-	assetsOnce = sync.Once{}
-	privateDir := path.Join(outDir, "private")
-	if err := mkdirp(privateDir); err != nil {
-		return errors.WithMessage(err, "could not create private directory")
+func joinSourcePath(src string) func(string) string {
+	return func(rel string) string {
+		return filepath.Join(src, rel)
 	}
-	publicDir := path.Join(outDir, "public")
-	if err := mkdirp(publicDir); err != nil {
-		return errors.WithMessage(err, "could not create public directory")
+}
+
+func build(ioConfig *IOConfig, config *config.Config, log *log.Logger) (*Result, error) {
+	outDir := ioConfig.Destination
+	joinSource := joinSourcePath(ioConfig.Source)
+	log.Debug("output", "dir", outDir)
+	r := &Result{
+		Hashes: make([]string, 0),
 	}
 
-	err := cp.Copy("static", publicDir, cp.Options{
-		PreserveTimes:     true,
-		PermissionControl: cp.AddPermission(0755),
-	})
+	err := copyRecursive(joinSource("static"), outDir)
 	if err != nil {
-		return errors.WithMessage(err, "could not copy static files")
+		return nil, errors.WithMessage(err, "could not copy static files")
 	}
 
-	if err := mkdirp(publicDir, "post"); err != nil {
-		return errors.WithMessage(err, "could not create post output directory")
+	if err := mkdirp(outDir, "post"); err != nil {
+		return nil, errors.WithMessage(err, "could not create post output directory")
 	}
 	log.Debug("reading posts")
-	posts, tags, err := readPosts("content", "post", publicDir)
+	posts, tags, err := content.ReadPosts(&content.Config{
+		Root:      joinSource("content"),
+		InputDir:  "post",
+		OutputDir: outDir,
+	}, log.Named("content"))
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	sm := NewSitemap(config)
+	sitemap := sitemap.New(config)
 	lastMod := time.Now()
 	if len(posts) > 0 {
 		lastMod = posts[0].Date
 	}
 
 	for _, post := range posts {
-		if err := mkdirp(publicDir, "post", post.Basename); err != nil {
-			return errors.WithMessage(err, "could not create directory for post")
+		if err := mkdirp(outDir, "post", post.Basename); err != nil {
+			return nil, errors.WithMessage(err, "could not create directory for post")
 		}
 		log.Debug("rendering post", "post", post.Basename)
-		sm.Add(&sitemap.URL{
-			Loc:     post.URL,
-			LastMod: &post.Date,
-		})
-		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
+		sitemap.AddPath(post.URL, post.Date)
+		if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil {
+			return nil, err
 		}
 	}
 
-	if err := mkdirp(publicDir, "tags"); err != nil {
-		return errors.WithMessage(err, "could not create directory for tags")
+	if err := mkdirp(outDir, "tags"); err != nil {
+		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 errors.WithMessage(err, "could not render tags")
+	if err := renderToFile(
+		templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"),
+		outDir,
+		"tags",
+		"index.html",
+	); err != nil {
+		return nil, err
 	}
-	if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil {
-		return err
-	}
-	sm.Add(&sitemap.URL{
-		Loc:     "/tags/",
-		LastMod: &lastMod,
-	})
+	sitemap.AddPath("/tags/", lastMod)
 
 	for _, tag := range tags.ToSlice() {
-		matchingPosts := []Post{}
+		matchingPosts := []content.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")
+		if err := mkdirp(outDir, "tags", tag); err != nil {
+			return nil, errors.WithMessage(err, "could not create directory")
 		}
 		log.Debug("rendering tags page", "tag", tag)
 		url := "/tags/" + tag
-		output, err := renderListPage(tag, config, matchingPosts, url)
-		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
+		if err := renderToFile(
+			templates.TagPage(config, tag, matchingPosts, url),
+			outDir,
+			"tags",
+			tag,
+			"index.html",
+		); err != nil {
+			return nil, err
 		}
-		sm.Add(&sitemap.URL{
-			Loc:     url,
-			LastMod: &matchingPosts[0].Date,
-		})
+		sitemap.AddPath(url, matchingPosts[0].Date)
 
 		log.Debug("rendering tags feed", "tag", tag)
-		output, err = renderFeed(
+		feed, 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")
+			return nil, errors.WithMessage(err, "could not render tag feed page")
 		}
-		if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil {
-			return err
+		if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil {
+			return nil, err
 		}
 	}
 
 	log.Debug("rendering list page")
-	listPage, err := renderListPage("", config, posts, "/post")
-	if err != nil {
-		return errors.WithMessage(err, "could not render list page")
+	if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil {
+		return nil, err
 	}
-	if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil {
-		return err
-	}
-	sm.Add(&sitemap.URL{
-		Loc:     "/post/",
-		LastMod: &lastMod,
-	})
+	sitemap.AddPath("/post/", lastMod)
 
 	log.Debug("rendering feed")
 	feed, err := renderFeed(config.Title, config, posts, "feed")
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed")
+		return nil, errors.WithMessage(err, "could not render feed")
 	}
-	if err := outputToFile(feed, publicDir, "atom.xml"); err != nil {
-		return err
+	if err := writerToFile(feed, outDir, "atom.xml"); err != nil {
+		return nil, err
 	}
 
 	log.Debug("rendering feed styles")
-	feedStyles, err := renderFeedStyles()
+	feedStyles, err := renderFeedStyles(ioConfig.Source)
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not render feed styles")
+	}
+	if err := outputToFile(feedStyles, outDir, "feed-styles.xsl"); err != nil {
+		return nil, err
+	}
+	_, err = feedStyles.Seek(0, 0)
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed styles")
+		return nil, err
 	}
-	if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil {
-		return err
+	h, err := getFeedStylesHash(feedStyles)
+	if err != nil {
+		return nil, err
 	}
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering homepage")
-	homePage, err := renderHomepage(config, posts, "/")
+	_, text, err := content.GetPost(joinSource(filepath.Join("content", "index.md")))
+	if err != nil {
+		return nil, err
+	}
+	content, err := content.RenderMarkdown(text)
 	if err != nil {
-		return errors.WithMessage(err, "could not render homepage")
+		return nil, err
 	}
-	if err := outputToFile(homePage, publicDir, "index.html"); err != nil {
-		return err
+	if err := renderToFile(templates.Homepage(config, posts, content), outDir, "index.html"); err != nil {
+		return nil, err
 	}
 	// it would be nice to set LastMod here, but using the latest post
 	// date would be wrong as the homepage has its own content file
 	// without a date, which could be newer
-	sm.Add(&sitemap.URL{
-		Loc: "/",
-	})
-
-	log.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, publicDir, "404.html"); err != nil {
-		return err
-	}
+	sitemap.AddPath("/", time.Time{})
+	h, _ = getHTMLStyleHash(outDir, "index.html")
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering sitemap")
-	if err := writerToFile(sm, publicDir, "sitemap.xml"); err != nil {
-		return err
+	if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil {
+		return nil, err
 	}
 
 	log.Debug("rendering robots.txt")
-	rob, err := renderRobotsTXT(config)
+	rob, err := renderRobotsTXT(ioConfig.Source, config)
 	if err != nil {
-		return err
+		return nil, err
 	}
-	if err := outputToFile(rob, publicDir, "robots.txt"); err != nil {
-		return err
+	if err := outputToFile(rob, outDir, "robots.txt"); err != nil {
+		return nil, err
 	}
 
-	return nil
+	return r, nil
 }
 
-func BuildSite(ioConfig IOConfig) error {
-	config, err := config.GetConfig()
-	if err != nil {
-		return errors.WithMessage(err, "could not get config")
+func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) {
+	if cfg == nil {
+		return nil, errors.New("config is nil")
 	}
-	config.InjectLiveReload = ioConfig.Development
+	cfg.InjectLiveReload = ioConfig.Development
+	compressFiles = !ioConfig.Development
 
-	if ioConfig.BaseURL.URL != nil {
-		config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String())
-		if err != nil {
-			return errors.WithMessage(err, "could not re-parse base URL")
-		}
-	}
-
-	if ioConfig.Development && ioConfig.Destination != "website" {
-		err = os.RemoveAll(ioConfig.Destination)
-		if err != nil {
-			return errors.WithMessage(err, "could not remove destination directory")
-		}
-	}
+	templates.Setup()
+	loadCSS(ioConfig.Source)
 
-	return build(ioConfig.Destination, *config)
+	return build(ioConfig, cfg, log)
 }
diff --git a/internal/builder/files.go b/internal/builder/files.go
new file mode 100644
index 0000000..a9046d7
--- /dev/null
+++ b/internal/builder/files.go
@@ -0,0 +1,120 @@
+package builder
+
+import (
+	"compress/gzip"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+
+	"github.com/andybalholm/brotli"
+)
+
+const (
+	gzipLevel   = 6
+	brotliLevel = 9
+)
+
+type MultiWriteCloser struct {
+	writers     []io.WriteCloser
+	multiWriter io.Writer
+}
+
+func (mw *MultiWriteCloser) Write(p []byte) (n int, err error) {
+	return mw.multiWriter.Write(p)
+}
+
+func (mw *MultiWriteCloser) Close() error {
+	var lastErr error
+	for _, w := range mw.writers {
+		err := w.Close()
+		if err != nil {
+			lastErr = err
+		}
+	}
+
+	return lastErr
+}
+
+func openFileWrite(filename string) (*os.File, error) {
+	return os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+}
+
+func openFileGz(filename string) (*gzip.Writer, error) {
+	filenameGz := filename + ".gz"
+	f, err := openFileWrite(filenameGz)
+	if err != nil {
+		return nil, err
+	}
+
+	return gzip.NewWriterLevel(f, gzipLevel)
+}
+
+func openFileBrotli(filename string) (*brotli.Writer, error) {
+	filenameBrotli := filename + ".br"
+	f, err := openFileWrite(filenameBrotli)
+	if err != nil {
+		return nil, err
+	}
+
+	return brotli.NewWriterLevel(f, brotliLevel), nil
+}
+
+func multiOpenFile(filename string) (*MultiWriteCloser, error) {
+	r, err := openFileWrite(filename)
+	if err != nil {
+		return nil, err
+	}
+	gz, err := openFileGz(filename)
+	if err != nil {
+		return nil, err
+	}
+	br, err := openFileBrotli(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	return &MultiWriteCloser{
+		writers:     []io.WriteCloser{r, gz, br},
+		multiWriter: io.MultiWriter(r, gz, br),
+	}, nil
+}
+
+func openFileAndVariants(filename string) (io.WriteCloser, error) {
+	if compressFiles {
+		return multiOpenFile(filename)
+	}
+
+	return openFileWrite(filename)
+}
+
+func copyRecursive(src, dst string) error {
+	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return mkdirp(dst, rel)
+		}
+
+		sf, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer sf.Close()
+		df, err := openFileAndVariants(filepath.Join(dst, rel))
+		if err != nil {
+			return err
+		}
+		defer df.Close()
+		if _, err := io.Copy(df, sf); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
diff --git a/internal/builder/hasher.go b/internal/builder/hasher.go
new file mode 100644
index 0000000..f0f9167
--- /dev/null
+++ b/internal/builder/hasher.go
@@ -0,0 +1,13 @@
+package builder
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+)
+
+func hash(s string) string {
+	shasum := sha256.New()
+	shasum.Write([]byte(s))
+
+	return "sha256-" + base64.StdEncoding.EncodeToString(shasum.Sum(nil))
+}
diff --git a/internal/builder/posts.go b/internal/builder/posts.go
deleted file mode 100644
index deae3e8..0000000
--- a/internal/builder/posts.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package builder
-
-import (
-	"bytes"
-	"os"
-	"path"
-	"path/filepath"
-	"slices"
-	"strings"
-	"time"
-	"website/internal/log"
-
-	"github.com/adrg/frontmatter"
-	mapset "github.com/deckarep/golang-set/v2"
-	"github.com/pkg/errors"
-	fences "github.com/stefanfritsch/goldmark-fences"
-	"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,
-		&fences.Extender{},
-	),
-)
-
-func getPost(filename string) (*PostMatter, []byte, error) {
-	matter := PostMatter{}
-	content, err := os.Open(filename)
-	if err != nil {
-		return nil, nil, errors.WithMessagef(err, "could not open post %s", filename)
-	}
-	defer content.Close()
-	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)
-			log.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))
-			}
-
-			log.Debug("rendering markdown in post", "post", pathFromRoot)
-			html, err := renderMarkdown(content)
-			if err != nil {
-				return nil, nil, err
-			}
-			post := Post{
-				Input:      pathFromRoot,
-				Output:     output,
-				Basename:   filepath.Base(url),
-				URL:        url,
-				PostMatter: *matter,
-				Content:    html,
-			}
-
-			posts = append(posts, post)
-		}
-	}
-	slices.SortFunc(posts, func(a, b Post) int {
-		return b.Date.Compare(a.Date)
-	})
-
-	return posts, tags, nil
-}
diff --git a/internal/builder/sitemap.go b/internal/builder/sitemap.go
deleted file mode 100644
index 81e3a31..0000000
--- a/internal/builder/sitemap.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package builder
-
-import (
-	"io"
-	"website/internal/config"
-
-	"github.com/snabb/sitemap"
-)
-
-type Sitemap struct {
-	config  *config.Config
-	Sitemap *sitemap.Sitemap
-}
-
-func NewSitemap(cfg config.Config) *Sitemap {
-	return &Sitemap{
-		config:  &cfg,
-		Sitemap: sitemap.New(),
-	}
-}
-
-func (s *Sitemap) Add(u *sitemap.URL) {
-	u.Loc = s.config.BaseURL.JoinPath(u.Loc).String()
-	s.Sitemap.Add(u)
-}
-
-func (s *Sitemap) WriteTo(w io.Writer) (int64, error) {
-	return s.Sitemap.WriteTo(w)
-}
diff --git a/internal/builder/template.go b/internal/builder/template.go
index ab36c85..9f019df 100644
--- a/internal/builder/template.go
+++ b/internal/builder/template.go
@@ -1,55 +1,41 @@
 package builder
 
 import (
+	"bytes"
 	"encoding/xml"
-	"fmt"
 	"io"
-	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
-	"sync"
 	"text/template"
-	"time"
-	"website/internal/atom"
-	"website/internal/config"
-	"website/internal/log"
+
+	"go.alanpearce.eu/website/internal/atom"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
 
 	"github.com/PuerkitoBio/goquery"
-	"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"
+	"gitlab.com/tozd/go/errors"
 )
 
 var (
-	assetsOnce     sync.Once
-	css            string
-	countHTML      *goquery.Document
-	liveReloadHTML *goquery.Document
-	templates      = make(map[string]*os.File)
+	css   string
+	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 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, errors.Wrapf(err, "could not load template at path %s", path)
-		}
-		templates[path] = file
+func loadCSS(source string) {
+	bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css"))
+	if err != nil {
+		panic(err)
 	}
-	file = templates[path]
-
-	return
+	css = string(bytes)
 }
 
-var (
-	imgOnce     sync.Once
-	img         *goquery.Selection
-	urlTemplate *url.URL
-)
-
 type QuerySelection struct {
 	*goquery.Selection
 }
@@ -68,237 +54,9 @@ 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) {
+func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) {
 	r, w := io.Pipe()
-	tpl, err := template.ParseFiles("templates/robots.tmpl")
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl"))
 	if err != nil {
 		return nil, err
 	}
@@ -311,55 +69,36 @@ func renderRobotsTXT(config config.Config) (io.Reader, error) {
 		}
 		w.Close()
 	}()
-	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
+	return r, nil
 }
 
 func renderFeed(
 	title string,
-	config config.Config,
-	posts []Post,
+	config *config.Config,
+	posts []content.Post,
 	specific string,
-) (io.Reader, error) {
-	reader, err := loadTemplate("templates/feed.xml")
+) (io.WriterTo, error) {
+	buf := &bytes.Buffer{}
+	datetime := posts[0].Date.UTC()
+
+	buf.WriteString(xml.Header)
+	err := atom.LinkXSL(buf, "/feed-styles.xsl")
 	if err != nil {
 		return nil, err
 	}
-	defer func() {
-		_, err := reader.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset reader: " + err.Error())
-		}
-	}()
-	doc, err := xmlquery.Parse(reader)
-	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
-	}
-	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()
-	if err != nil {
-		return nil, errors.Wrap(err, "could not convert post date to text")
+	feed := &atom.Feed{
+		Title:   title,
+		Link:    atom.MakeLink(config.BaseURL.URL),
+		ID:      atom.MakeTagURI(config, specific),
+		Updated: datetime,
+		Entries: make([]*atom.FeedEntry, len(posts)),
 	}
-	feed.SelectElement("updated").FirstChild.Data = string(datetime)
-	tpl := feed.SelectElement("entry")
-	xmlquery.RemoveFromTree(tpl)
 
-	for _, post := range posts {
-		fullURL := config.BaseURL.JoinPath(post.URL).String()
-		text, err := xml.MarshalIndent(&atom.FeedEntry{
+	for i, post := range posts {
+		feed.Entries[i] = &atom.FeedEntry{
 			Title:   post.Title,
-			Link:    atom.MakeLink(fullURL),
+			Link:    atom.MakeLink(config.BaseURL.JoinPath(post.URL)),
 			ID:      atom.MakeTagURI(config, post.Basename),
 			Updated: post.Date.UTC(),
 			Summary: post.Description,
@@ -368,80 +107,66 @@ func renderFeed(
 				Content: post.Content,
 				Type:    "html",
 			},
-		}, "  ", "    ")
-		if err != nil {
-			return nil, errors.Wrap(err, "could not marshal xml")
-		}
-		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, errors.Wrap(err, "could not parse XML")
 		}
-		xmlquery.AddChild(feed, entry.SelectElement("entry"))
+	}
+	enc := xml.NewEncoder(buf)
+	err = enc.Encode(feed)
+	if err != nil {
+		return nil, err
 	}
 
-	return strings.NewReader(doc.OutputXML(true)), nil
+	return buf, nil
 }
 
-func renderFeedStyles() (io.Reader, error) {
-	reader, err := loadTemplate("templates/feed-styles.xsl")
+func renderFeedStyles(source string) (*strings.Reader, error) {
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl"))
 	if err != nil {
 		return nil, err
 	}
-	defer func() {
-		_, err := reader.Seek(0, io.SeekStart)
-		if err != nil {
-			panic("could not reset reader: " + err.Error())
-		}
-	}()
-	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",
+
+	esc := &strings.Builder{}
+	err = xml.EscapeText(esc, []byte(css))
+	if err != nil {
+		return nil, err
 	}
-	doc, err := xmlquery.Parse(reader)
+
+	w := &strings.Builder{}
+	err = tpl.Execute(w, map[string]interface{}{
+		"css": esc.String(),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return strings.NewReader(w.String()), nil
+}
+
+func getFeedStylesHash(r io.Reader) (string, error) {
+	doc, err := xmlquery.Parse(r)
 	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
+		return "", err
 	}
 	expr, err := xpath.CompileWithNS("//xhtml:style", nsMap)
 	if err != nil {
-		return nil, errors.Wrap(err, "could not parse XML")
+		return "", errors.Wrap(err, "could not parse XPath")
 	}
 	style := xmlquery.QuerySelector(doc, expr)
-	xmlquery.AddChild(style, &xmlquery.Node{
-		Type: xmlquery.TextNode,
-		Data: css,
-	})
 
-	return strings.NewReader(doc.OutputXML(true)), nil
+	return hash(style.InnerText()), nil
 }
 
-func renderHTML(doc *goquery.Document) io.Reader {
-	r, w := io.Pipe()
-
-	go func() {
-		_, err := w.Write([]byte("<!doctype html>\n"))
-		if err != nil {
-			log.Error("error writing doctype", "error", err)
-			w.CloseWithError(err)
-		}
-		err = htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)})
-		if err != nil {
-			log.Error("error rendering html", "error", err)
-			w.CloseWithError(err)
-
-			return
-		}
-		defer w.Close()
-	}()
+func getHTMLStyleHash(filenames ...string) (string, error) {
+	fn := filepath.Join(filenames...)
+	f, err := os.Open(fn)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	doc, err := NewDocumentFromReader(f)
+	if err != nil {
+		return "", err
+	}
+	html := doc.Find("head > style").Text()
 
-	return r
+	return hash(html), nil
 }