about summary refs log tree commit diff stats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/build/build.go196
-rw-r--r--cmd/build/main.go8
-rw-r--r--cmd/build/posts.go121
-rw-r--r--cmd/build/template.go359
-rw-r--r--cmd/cspgenerator/cspgenerator.go13
-rw-r--r--cmd/dev/main.go313
-rw-r--r--cmd/server/filemap.go77
-rw-r--r--cmd/server/logging.go55
-rw-r--r--cmd/server/main.go50
-rw-r--r--cmd/server/server.go161
10 files changed, 371 insertions, 982 deletions
diff --git a/cmd/build/build.go b/cmd/build/build.go
deleted file mode 100644
index 5daa940..0000000
--- a/cmd/build/build.go
+++ /dev/null
@@ -1,196 +0,0 @@
-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
index 9b6a79b..069f9bd 100644
--- a/cmd/build/main.go
+++ b/cmd/build/main.go
@@ -7,6 +7,8 @@ import (
 	"log/slog"
 	"os"
 
+	"website/internal/builder"
+
 	"github.com/BurntSushi/toml"
 	"github.com/ardanlabs/conf/v3"
 	"github.com/pkg/errors"
@@ -16,9 +18,11 @@ func main() {
 	if os.Getenv("DEBUG") != "" {
 		slog.SetLogLoggerLevel(slog.LevelDebug)
 	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("build: ")
 	slog.Debug("starting build process")
 
-	ioConfig := IOConfig{}
+	ioConfig := builder.IOConfig{}
 	if help, err := conf.Parse("", &ioConfig); err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
 			fmt.Println(help)
@@ -34,7 +38,7 @@ func main() {
 		}
 	}
 
-	if err := buildSite(ioConfig); err != nil {
+	if err := builder.BuildSite(ioConfig); err != nil {
 		switch cause := errors.Cause(err).(type) {
 		case *fs.PathError:
 			slog.Info("pathError")
diff --git a/cmd/build/posts.go b/cmd/build/posts.go
deleted file mode 100644
index f03caf3..0000000
--- a/cmd/build/posts.go
+++ /dev/null
@@ -1,121 +0,0 @@
-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
deleted file mode 100644
index 84cf57c..0000000
--- a/cmd/build/template.go
+++ /dev/null
@@ -1,359 +0,0 @@
-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/cspgenerator/cspgenerator.go b/cmd/cspgenerator/cspgenerator.go
new file mode 100644
index 0000000..f79a591
--- /dev/null
+++ b/cmd/cspgenerator/cspgenerator.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"log"
+	"website/internal/config"
+)
+
+func main() {
+	err := config.GenerateCSP()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/cmd/dev/main.go b/cmd/dev/main.go
new file mode 100644
index 0000000..1a6ccea
--- /dev/null
+++ b/cmd/dev/main.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"io/fs"
+	"log"
+	"log/slog"
+	"net/http"
+	"net/http/httputil"
+
+	"os"
+	"os/exec"
+	"os/signal"
+	"path"
+	"path/filepath"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"website/internal/config"
+
+	"github.com/antage/eventsource"
+	"github.com/ardanlabs/conf/v3"
+	"github.com/gohugoio/hugo/watcher"
+
+	"github.com/pkg/errors"
+)
+
+type DevConfig struct {
+	Source    string     `conf:"default:.,short:s"`
+	TempDir   string     `conf:"required,short:t"`
+	BaseURL   config.URL `conf:"default:http://localhost:3000"`
+	ServerURL config.URL `conf:"default:http://localhost:3001"`
+}
+
+func RunCommandPiped(ctx context.Context, command string, args ...string) (cmd *exec.Cmd, err error) {
+	slog.Debug(fmt.Sprintf("running command %s %s", command, strings.Join(args, " ")))
+	cmd = exec.CommandContext(ctx, command, args...)
+	cmd.Env = append(os.Environ(), "DEBUG=")
+	cmd.Cancel = func() error {
+		slog.Debug("signalling child")
+		err := cmd.Process.Signal(os.Interrupt)
+		if err != nil {
+			slog.Error(fmt.Sprintf("signal error: %v", err))
+		}
+		return err
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return
+	}
+
+	go io.Copy(os.Stdout, stdout)
+	go io.Copy(os.Stderr, stderr)
+
+	return
+}
+
+type FileWatcher struct {
+	*watcher.Batcher
+}
+
+func NewFileWatcher(pollTime time.Duration) (*FileWatcher, error) {
+	batcher, err := watcher.New(pollTime/5, pollTime, true)
+	if err != nil {
+		return nil, err
+	}
+	return &FileWatcher{batcher}, nil
+}
+
+func (watcher FileWatcher) WatchAllFiles(from string) error {
+	slog.Debug(fmt.Sprintf("watching files under %s", from))
+	err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		// slog.Debug(fmt.Sprintf("adding file %s to watcher", path))
+		if err = watcher.Add(path); err != nil {
+			return err
+		}
+		return nil
+	})
+	return err
+}
+
+func build(ctx context.Context, config DevConfig) error {
+	buildExe := filepath.Join(config.TempDir, "build")
+	cmd, err := RunCommandPiped(ctx, buildExe,
+		"--dest", path.Join(config.TempDir, "output"),
+		"--dev",
+	)
+	// cmd, err := RunCommandPiped(ctx, "./devfakebuild")
+
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+
+	err = cmd.Run()
+	slog.Debug(fmt.Sprintf("build command exited with code %d", cmd.ProcessState.ExitCode()))
+	if err != nil {
+		return errors.WithMessage(err, "error running build command")
+	}
+	return nil
+}
+
+func server(ctx context.Context, devConfig DevConfig) error {
+	serverExe := path.Join(devConfig.TempDir, "server")
+
+	cmd, err := RunCommandPiped(ctx,
+		serverExe,
+		"--port", devConfig.ServerURL.Port(),
+		"--root", path.Join(devConfig.TempDir, "output"),
+		"--in-dev-server",
+	)
+	if err != nil {
+		return errors.WithMessage(err, "error running server command")
+	}
+	// cmd.Env = append(cmd.Env, "DEBUG=1")
+
+	cmdErr := make(chan error, 1)
+	done := make(chan struct{})
+	err = cmd.Start()
+	if err != nil {
+		return errors.WithMessage(err, fmt.Sprintf("error starting server binary"))
+	}
+
+	go func() {
+		err := cmd.Wait()
+		if err == nil && cmd.ProcessState.Exited() {
+			err = errors.Errorf("server exited unexpectedly")
+		}
+
+		cmdErr <- err
+		close(done)
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			slog.Debug("server context done")
+			cmd.Process.Signal(os.Interrupt)
+			<-done
+		case err := <-cmdErr:
+			return err
+		}
+	}
+}
+
+func main() {
+	if os.Getenv("DEBUG") != "" {
+		slog.SetLogLoggerLevel(slog.LevelDebug)
+	}
+	var wg sync.WaitGroup
+
+	devConfig := DevConfig{}
+	help, err := conf.Parse("", &devConfig)
+	if err != nil {
+		if errors.Is(err, conf.ErrHelpWanted) {
+			fmt.Println(help)
+			os.Exit(1)
+		}
+		log.Panicf("parsing dev configuration: %v", err)
+	}
+
+	slog.Debug(fmt.Sprintf("using folder %s for build output", devConfig.TempDir))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	slog.Debug("setting interrupt handler")
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		sig := <-c
+		slog.Info(fmt.Sprintf("shutting down on signal %d", sig))
+		cancel()
+		sig = <-c
+		slog.Info(fmt.Sprintf("got second signal, dying %d", sig))
+		os.Exit(1)
+	}()
+
+	serverChan := make(chan bool, 1)
+	eventsource := eventsource.New(nil, nil)
+	defer eventsource.Close()
+	srv := http.Server{
+		Addr: devConfig.BaseURL.Host,
+	}
+	devCtx, devCancel := context.WithTimeout(ctx, 1*time.Second)
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer devCancel()
+		slog.Debug("waiting for first server launch")
+		<-serverChan
+		slog.Debug("got first server launch event")
+
+		http.Handle("/", &httputil.ReverseProxy{
+			Rewrite: func(req *httputil.ProxyRequest) {
+				req.SetURL(devConfig.ServerURL.URL)
+				req.Out.Host = req.In.Host
+			},
+		})
+		http.Handle("/_/reload", eventsource)
+		done := make(chan bool)
+		go func() {
+			err := srv.ListenAndServe()
+			if err != nil && err != http.ErrServerClosed {
+				slog.Error(err.Error())
+				cancel()
+			}
+			done <- true
+		}()
+		go func() {
+			for {
+				select {
+				case ready := <-serverChan:
+					if ready {
+						slog.Debug("sending reload message")
+						eventsource.SendEventMessage("reload", "", "")
+					} else {
+						slog.Debug("server not ready")
+					}
+				}
+			}
+		}()
+		slog.Info(fmt.Sprintf("dev server listening on %s", devConfig.BaseURL.Host))
+		<-done
+		slog.Debug("dev server closed")
+	}()
+
+	fw, err := NewFileWatcher(500 * time.Millisecond)
+	if err != nil {
+		log.Fatalf("error creating file watcher: %v", err)
+	}
+	err = fw.WatchAllFiles("content")
+	if err != nil {
+		log.Fatalf("could not watch files in content directory: %v", err)
+	}
+	err = fw.WatchAllFiles("templates")
+	if err != nil {
+		log.Fatalf("could not watch files in templates directory: %v", err)
+	}
+
+	var exitCode int
+	serverErr := make(chan error, 1)
+loop:
+	for {
+		serverCtx, stopServer := context.WithCancel(ctx)
+		slog.Debug("starting build")
+
+		err := build(ctx, devConfig)
+		if err != nil {
+			slog.Error(fmt.Sprintf("build error: %v", err))
+			// don't set up the server until there's a FS change event
+		} else {
+			slog.Debug("setting up server")
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				serverChan <- true
+				serverErr <- server(serverCtx, devConfig)
+			}()
+		}
+
+		select {
+		case <-ctx.Done():
+			slog.Debug("main context cancelled")
+			slog.Debug("calling server shutdown")
+			srv.Shutdown(devCtx)
+			exitCode = 1
+			break loop
+		case event := <-fw.Events:
+			slog.Debug(fmt.Sprintf("event received: %v", event))
+			stopServer()
+			serverChan <- false
+			slog.Debug("waiting for server shutdown")
+			<-serverErr
+			slog.Debug("server shutdown completed")
+			continue
+		case err = <-serverErr:
+			if err != nil && err != context.Canceled {
+				var exerr *exec.ExitError
+				slog.Error(fmt.Sprintf("server reported error: %v", err))
+				if errors.As(err, &exerr) {
+					slog.Debug("server exit error")
+					exitCode = exerr.ExitCode()
+				} else {
+					slog.Debug("server other error")
+					exitCode = 1
+				}
+				break
+			}
+			slog.Debug("no error or server context cancelled")
+			continue
+		}
+
+		slog.Debug("waiting on server")
+		exitCode = 0
+		break
+	}
+
+	slog.Debug("waiting for wg before shutting down")
+	wg.Wait()
+	os.Exit(exitCode)
+}
diff --git a/cmd/server/filemap.go b/cmd/server/filemap.go
deleted file mode 100644
index 5f7e1bb..0000000
--- a/cmd/server/filemap.go
+++ /dev/null
@@ -1,77 +0,0 @@
-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
deleted file mode 100644
index 601baab..0000000
--- a/cmd/server/logging.go
+++ /dev/null
@@ -1,55 +0,0 @@
-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
index b6817d8..bae215a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -5,7 +5,10 @@ import (
 	"log"
 	"log/slog"
 	"os"
-	cfg "website/internal/config"
+	"os/signal"
+	"sync"
+
+	"website/internal/server"
 
 	"github.com/ardanlabs/conf/v3"
 	"github.com/pkg/errors"
@@ -16,20 +19,14 @@ var (
 	ShortSHA  string
 )
 
-type Config struct {
-	Production             bool    `conf:"default:false"`
-	ListenAddress          string  `conf:"default:localhost"`
-	Port                   uint16  `conf:"default:3000,short:p"`
-	BaseURL                cfg.URL `conf:"default:http://localhost:3000,short:b"`
-	RedirectOtherHostnames bool    `conf:"default:false"`
-}
-
 func main() {
 	if os.Getenv("DEBUG") != "" {
 		slog.SetLogLoggerLevel(slog.LevelDebug)
 	}
+	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+	log.SetPrefix("server: ")
 
-	runtimeConfig := Config{}
+	runtimeConfig := server.Config{}
 	help, err := conf.Parse("", &runtimeConfig)
 	if err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
@@ -39,5 +36,36 @@ func main() {
 		log.Panicf("parsing runtime configuration: %v", err)
 	}
 
-	startServer(&runtimeConfig)
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt)
+	sv, err := server.New(&runtimeConfig)
+	if err != nil {
+		log.Fatalf("error setting up server: %v", err)
+	}
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sig := <-c
+		log.Printf("signal captured: %v", sig)
+		<-sv.Stop()
+		slog.Debug("server stopped")
+	}()
+
+	sErr := make(chan error)
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		sErr <- sv.Start()
+	}()
+	if !runtimeConfig.InDevServer {
+		log.Printf("server listening on %s", sv.Addr)
+	}
+
+	err = <-sErr
+	if err != nil {
+		// Error starting or closing listener:
+		log.Fatalf("error: %v", err)
+	}
+	wg.Wait()
 }
diff --git a/cmd/server/server.go b/cmd/server/server.go
deleted file mode 100644
index 9a1e48a..0000000
--- a/cmd/server/server.go
+++ /dev/null
@@ -1,161 +0,0 @@
-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))
-}