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.go271
-rw-r--r--internal/builder/files.go120
-rw-r--r--internal/builder/hasher.go13
-rw-r--r--internal/builder/template.go172
4 files changed, 576 insertions, 0 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
new file mode 100644
index 0000000..b99d919
--- /dev/null
+++ b/internal/builder/builder.go
@@ -0,0 +1,271 @@
+package builder
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+	"slices"
+	"time"
+
+	"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"
+
+	"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: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, 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 := io.Copy(file, output); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+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")
+	}
+	defer file.Close()
+
+	if _, err := writer.WriteTo(file); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+func joinSourcePath(src string) func(string) string {
+	return func(rel string) string {
+		return filepath.Join(src, rel)
+	}
+}
+
+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 := copyRecursive(joinSource("static"), outDir)
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not copy static files")
+	}
+
+	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 := content.ReadPosts(&content.Config{
+		Root:      joinSource("content"),
+		InputDir:  "post",
+		OutputDir: outDir,
+	}, log.Named("content"))
+	if err != nil {
+		return nil, err
+	}
+
+	sitemap := sitemap.New(config)
+	lastMod := time.Now()
+	if len(posts) > 0 {
+		lastMod = posts[0].Date
+	}
+
+	for _, post := range posts {
+		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)
+		sitemap.AddPath(post.URL, post.Date)
+		if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil {
+			return nil, err
+		}
+	}
+
+	if err := mkdirp(outDir, "tags"); err != nil {
+		return nil, errors.WithMessage(err, "could not create directory for tags")
+	}
+	log.Debug("rendering tags list")
+	if err := renderToFile(
+		templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"),
+		outDir,
+		"tags",
+		"index.html",
+	); err != nil {
+		return nil, err
+	}
+	sitemap.AddPath("/tags/", lastMod)
+
+	for _, tag := range tags.ToSlice() {
+		matchingPosts := []content.Post{}
+		for _, post := range posts {
+			if slices.Contains(post.Taxonomies.Tags, tag) {
+				matchingPosts = append(matchingPosts, post)
+			}
+		}
+		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
+		if err := renderToFile(
+			templates.TagPage(config, tag, matchingPosts, url),
+			outDir,
+			"tags",
+			tag,
+			"index.html",
+		); err != nil {
+			return nil, err
+		}
+		sitemap.AddPath(url, matchingPosts[0].Date)
+
+		log.Debug("rendering tags feed", "tag", tag)
+		feed, err := renderFeed(
+			fmt.Sprintf("%s - %s", config.Title, tag),
+			config,
+			matchingPosts,
+			tag,
+		)
+		if err != nil {
+			return nil, errors.WithMessage(err, "could not render tag feed page")
+		}
+		if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil {
+			return nil, err
+		}
+	}
+
+	log.Debug("rendering list page")
+	if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil {
+		return nil, err
+	}
+	sitemap.AddPath("/post/", lastMod)
+
+	log.Debug("rendering feed")
+	feed, err := renderFeed(config.Title, config, posts, "feed")
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not render feed")
+	}
+	if err := writerToFile(feed, outDir, "atom.xml"); err != nil {
+		return nil, err
+	}
+
+	log.Debug("rendering feed styles")
+	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 nil, err
+	}
+	h, err := getFeedStylesHash(feedStyles)
+	if err != nil {
+		return nil, err
+	}
+	r.Hashes = append(r.Hashes, h)
+
+	log.Debug("rendering homepage")
+	_, 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 nil, 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
+	sitemap.AddPath("/", time.Time{})
+	h, _ = getHTMLStyleHash(outDir, "index.html")
+	r.Hashes = append(r.Hashes, h)
+
+	log.Debug("rendering sitemap")
+	if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil {
+		return nil, err
+	}
+
+	log.Debug("rendering robots.txt")
+	rob, err := renderRobotsTXT(ioConfig.Source, config)
+	if err != nil {
+		return nil, err
+	}
+	if err := outputToFile(rob, outDir, "robots.txt"); err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
+func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) {
+	if cfg == nil {
+		return nil, errors.New("config is nil")
+	}
+	cfg.InjectLiveReload = ioConfig.Development
+	compressFiles = !ioConfig.Development
+
+	templates.Setup()
+	loadCSS(ioConfig.Source)
+
+	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/template.go b/internal/builder/template.go
new file mode 100644
index 0000000..9f019df
--- /dev/null
+++ b/internal/builder/template.go
@@ -0,0 +1,172 @@
+package builder
+
+import (
+	"bytes"
+	"encoding/xml"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"go.alanpearce.eu/website/internal/atom"
+	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/content"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/antchfx/xmlquery"
+	"github.com/antchfx/xpath"
+	"gitlab.com/tozd/go/errors"
+)
+
+var (
+	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 loadCSS(source string) {
+	bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css"))
+	if err != nil {
+		panic(err)
+	}
+	css = string(bytes)
+}
+
+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}, errors.Wrap(err, "could not create query document")
+}
+
+func (q *QueryDocument) Find(selector string) *QuerySelection {
+	return &QuerySelection{q.Document.Find(selector)}
+}
+
+func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) {
+	r, w := io.Pipe()
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl"))
+	if err != nil {
+		return nil, err
+	}
+	go func() {
+		err = tpl.Execute(w, map[string]interface{}{
+			"BaseURL": config.BaseURL,
+		})
+		if err != nil {
+			w.CloseWithError(err)
+		}
+		w.Close()
+	}()
+
+	return r, nil
+}
+
+func renderFeed(
+	title string,
+	config *config.Config,
+	posts []content.Post,
+	specific string,
+) (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
+	}
+	feed := &atom.Feed{
+		Title:   title,
+		Link:    atom.MakeLink(config.BaseURL.URL),
+		ID:      atom.MakeTagURI(config, specific),
+		Updated: datetime,
+		Entries: make([]*atom.FeedEntry, len(posts)),
+	}
+
+	for i, post := range posts {
+		feed.Entries[i] = &atom.FeedEntry{
+			Title:   post.Title,
+			Link:    atom.MakeLink(config.BaseURL.JoinPath(post.URL)),
+			ID:      atom.MakeTagURI(config, post.Basename),
+			Updated: post.Date.UTC(),
+			Summary: post.Description,
+			Author:  config.Title,
+			Content: atom.FeedContent{
+				Content: post.Content,
+				Type:    "html",
+			},
+		}
+	}
+	enc := xml.NewEncoder(buf)
+	err = enc.Encode(feed)
+	if err != nil {
+		return nil, err
+	}
+
+	return buf, nil
+}
+
+func renderFeedStyles(source string) (*strings.Reader, error) {
+	tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl"))
+	if err != nil {
+		return nil, err
+	}
+
+	esc := &strings.Builder{}
+	err = xml.EscapeText(esc, []byte(css))
+	if err != nil {
+		return nil, err
+	}
+
+	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 "", err
+	}
+	expr, err := xpath.CompileWithNS("//xhtml:style", nsMap)
+	if err != nil {
+		return "", errors.Wrap(err, "could not parse XPath")
+	}
+	style := xmlquery.QuerySelector(doc, expr)
+
+	return hash(style.InnerText()), nil
+}
+
+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 hash(html), nil
+}