diff options
Diffstat (limited to 'internal/builder/builder.go')
-rw-r--r-- | internal/builder/builder.go | 271 |
1 files changed, 271 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) +} |