package builder
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"slices"
"time"
"alin.ovh/homestead/domain/content"
"alin.ovh/homestead/domain/content/builder/template"
ctemplates "alin.ovh/homestead/domain/content/templates"
"alin.ovh/homestead/domain/indieweb/sitemap"
vtemplates "alin.ovh/homestead/domain/vanity/templates"
"alin.ovh/homestead/domain/web/templates"
"alin.ovh/homestead/shared/buffer"
"alin.ovh/homestead/shared/config"
"alin.ovh/homestead/shared/storage"
"alin.ovh/homestead/shared/vcs"
"alin.ovh/x/log"
"github.com/Southclaws/fault"
"github.com/Southclaws/fault/fmsg"
mapset "github.com/deckarep/golang-set/v2"
)
type Options struct {
Source string `conf:"default:.,short:s,flag:src"`
Development bool `conf:"default:false,flag:dev"`
VCSRemoteURL config.URL `conf:"default:https://git.alin.ovh/website"`
Storage storage.Writer `conf:"-"`
Repo *vcs.Repository `conf:"-"`
}
var feedHeaders = map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "3600",
}
func joinSourcePath(src string) func(string) string {
return func(rel string) string {
return filepath.Join(src, rel)
}
}
func copyFile(storage storage.Writer, src string, rel string) error {
buf := new(buffer.Buffer)
sf, err := os.Open(src)
if err != nil {
return fault.Wrap(err)
}
defer sf.Close()
buf.Reset()
if _, err := io.Copy(buf, sf); err != nil {
return fault.Wrap(err)
}
if err := storage.Write("/"+rel, "", buf); err != nil {
return fault.Wrap(err)
}
return nil
}
func build(
options *Options,
config *config.Config,
log *log.Logger,
) error {
buf := new(buffer.Buffer)
joinSource := joinSourcePath(options.Source)
stor := options.Storage
postDir := "post"
siteSettings := templates.SiteSettings{
Title: config.Title,
Language: config.Language,
Menu: config.Menu,
InjectLiveReload: options.Development,
}
log.Debug("reading posts", "source", options.Source)
cc, err := content.NewContentCollection(&content.Config{
Root: options.Source,
PostDir: postDir,
Repo: options.Repo,
StaticFiles: config.StaticFiles,
}, log.Named("content"))
if err != nil {
return fault.Wrap(err)
}
sitemap := sitemap.New(config)
lastMod := time.Now()
if len(cc.Posts) > 0 {
lastMod = cc.Posts[0].Date
}
for _, post := range cc.Posts {
log.Debug("rendering post", "post", post.Basename)
sitemap.AddPath(post.URL, post.Date)
buf.Reset()
if err := ctemplates.PostPage(siteSettings, post).Render(buf); err != nil {
return fault.Wrap(err, fmsg.With("could not render post"))
}
if err := stor.WritePost(post, buf); err != nil {
return fault.Wrap(err)
}
}
log.Debug("rendering tags list")
buf.Reset()
if err := ctemplates.TagsPage(siteSettings, ctemplates.TagsPageVars{
Title: "Tags",
Tags: mapset.Sorted(cc.Tags),
}).Render(buf); err != nil {
return fault.Wrap(err)
}
if err := stor.Write("/tags/", "Tags", buf); err != nil {
return fault.Wrap(err)
}
sitemap.AddPath("/tags/", lastMod)
for _, tag := range cc.Tags.ToSlice() {
matchingPosts := []*content.Post{}
for _, post := range cc.Posts {
if slices.Contains(post.Taxonomies.Tags, tag) {
matchingPosts = append(matchingPosts, post)
}
}
log.Debug("rendering tags page", "tag", tag)
url := path.Join("/tags", tag) + "/"
buf.Reset()
if err := ctemplates.TagPage(siteSettings, ctemplates.TagPageVars{
Tag: tag,
Posts: matchingPosts,
}).Render(buf); err != nil {
return fault.Wrap(err)
}
if err = stor.Write(url, tag, buf); err != nil {
return fault.Wrap(err)
}
sitemap.AddPath(url, matchingPosts[0].Date)
log.Debug("rendering tags feed", "tag", tag)
title := fmt.Sprintf("%s - %s", config.Title, tag)
feed, err := template.RenderFeed(
title,
config,
matchingPosts,
tag,
)
if err != nil {
return fault.Wrap(err, fmsg.With("could not render tag feed page"))
}
buf.Reset()
if _, err := feed.WriteTo(buf); err != nil {
return fault.Wrap(err)
}
file := &storage.File{
Title: title,
LastModified: matchingPosts[0].Date,
ContentType: "application/xml",
Headers: feedHeaders,
Path: path.Join("/tags", tag, "atom.xml"),
FSPath: path.Join("/tags", tag, "atom.xml"),
}
if err := stor.WriteFile(file, buf); err != nil {
return fault.Wrap(err)
}
}
log.Debug("rendering list page")
buf.Reset()
if err := ctemplates.ListPage(siteSettings, ctemplates.ListPageVars{
Posts: cc.Posts,
}).Render(buf); err != nil {
return fault.Wrap(err)
}
if err := stor.Write(path.Join("/", postDir)+"/", "Posts", buf); err != nil {
return fault.Wrap(err)
}
sitemap.AddPath(path.Join("/", postDir)+"/", lastMod)
log.Debug("rendering feed")
feed, err := template.RenderFeed(config.Title, config, cc.Posts, "feed")
if err != nil {
return fault.Wrap(err, fmsg.With("could not render feed"))
}
buf.Reset()
if _, err := feed.WriteTo(buf); err != nil {
return fault.Wrap(err)
}
file := &storage.File{
Title: config.Title,
LastModified: cc.Posts[0].Date,
ContentType: "application/xml",
Headers: feedHeaders,
Path: "/atom.xml",
FSPath: "/atom.xml",
}
if err := stor.WriteFile(file, buf); err != nil {
return fault.Wrap(err)
}
for _, filename := range []string{"feed-styles.xsl", "style.css"} {
buf.Reset()
log.Debug("rendering template file", "filename", filename)
if err := template.CopyFile(filename, buf); err != nil {
return fault.Wrap(
err,
fmsg.With(fmt.Sprintf("could not render template file %s", filename)),
)
}
if err := stor.Write("/"+filename, "", buf); err != nil {
return fault.Wrap(err)
}
}
for _, post := range cc.Pages {
buf.Reset()
log.Debug("rendering page", "source", post.Input, "path", post.URL)
if post.URL == "/" {
if err := ctemplates.Homepage(siteSettings, ctemplates.HomepageVars{
Email: config.PublicEmail,
Me: config.RelMe,
Posts: cc.Posts,
}, post).Render(buf); err != nil {
return fault.Wrap(err)
}
} else {
if err := ctemplates.Page(siteSettings, post).Render(buf); err != nil {
return fault.Wrap(err)
}
}
file := stor.NewFileFromPost(post)
file.ContentType = "text/html; charset=utf-8"
if err := stor.WriteFile(file, buf); err != nil {
return fault.Wrap(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{})
if err := buf.SeekStart(); err != nil {
return fault.Wrap(err)
}
log.Debug("rendering sitemap")
buf.Reset()
if _, err := sitemap.WriteTo(buf); err != nil {
return fault.Wrap(err)
}
if err := stor.Write("/sitemap.xml", "sitemap", buf); err != nil {
return fault.Wrap(err)
}
log.Debug("rendering robots.txt")
buf.Reset()
err = template.RenderRobotsTXT(config.BaseURL, buf)
if err != nil {
return fault.Wrap(err)
}
if err := stor.Write("/robots.txt", "", buf); err != nil {
return fault.Wrap(err)
}
for _, sf := range cc.StaticFiles {
src := joinSource(sf)
log.Debug("copying static file", "sf", sf, "src", src)
err = copyFile(stor, src, sf)
if err != nil {
return fault.Wrap(err)
}
}
log.Debug("rendering go packages")
for _, p := range config.Go.Packages {
buf.Reset()
if err := vtemplates.GoPackagePage(siteSettings, &config.Go, p).Render(buf); err != nil {
return fault.Wrap(err)
}
if err := stor.Write(fmt.Sprintf("/go/%s.html", p), siteSettings.Title, buf); err != nil {
return fault.Wrap(err)
}
}
buf.Reset()
if err := vtemplates.GoPackageListPage(siteSettings, &config.Go).Render(buf); err != nil {
return fault.Wrap(err)
}
if err := stor.Write("/go/", siteSettings.Title, buf); err != nil {
return fault.Wrap(err)
}
return nil
}
func BuildSite(options *Options, cfg *config.Config, log *log.Logger) error {
if cfg == nil {
return fault.New("config is nil")
}
return build(options, cfg, log)
}
domain/content/builder/builder.go (view raw)