diff options
Diffstat (limited to 'internal/builder')
-rw-r--r-- | internal/builder/builder.go | 271 | ||||
-rw-r--r-- | internal/builder/files.go | 120 | ||||
-rw-r--r-- | internal/builder/hasher.go | 13 | ||||
-rw-r--r-- | internal/builder/template.go | 172 |
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 +} |