package builder import ( "fmt" "io" "os" "path" "path/filepath" "slices" "time" "go.alanpearce.eu/homestead/internal/buffer" "go.alanpearce.eu/homestead/internal/builder/template" "go.alanpearce.eu/homestead/internal/config" "go.alanpearce.eu/homestead/internal/content" "go.alanpearce.eu/homestead/internal/sitemap" "go.alanpearce.eu/homestead/internal/storage" "go.alanpearce.eu/homestead/internal/vcs" "go.alanpearce.eu/homestead/templates" "go.alanpearce.eu/x/log" mapset "github.com/deckarep/golang-set/v2" "gitlab.com/tozd/go/errors" ) 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.alanpearce.eu/website"` Storage storage.Writer `conf:"-"` Repo *vcs.Repository `conf:"-"` } 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) errors.E { buf := new(buffer.Buffer) sf, err := os.Open(src) if err != nil { return errors.WithStack(err) } defer sf.Close() buf.Reset() if _, err := io.Copy(buf, sf); err != nil { return errors.WithStack(err) } if err := storage.Write("/"+rel, "", buf); err != nil { return errors.WithStack(err) } return nil } func build( options *Options, config *config.Config, log *log.Logger, ) errors.E { buf := new(buffer.Buffer) joinSource := joinSourcePath(options.Source) storage := 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, }, log.Named("content")) if err != nil { return errors.WithStack(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 := templates.PostPage(siteSettings, post).Render(buf); err != nil { return errors.WithMessage(err, "could not render post") } if err := storage.WritePost(post, buf); err != nil { return errors.WithStack(err) } } log.Debug("rendering tags list") buf.Reset() if err := templates.TagsPage(siteSettings, templates.TagsPageVars{ Title: "Tags", Tags: mapset.Sorted(cc.Tags), }).Render(buf); err != nil { return errors.WithStack(err) } if err := storage.Write("/tags/", "Tags", buf); err != nil { return errors.WithStack(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 := templates.TagPage(siteSettings, templates.TagPageVars{ Tag: tag, Posts: matchingPosts, }).Render(buf); err != nil { return errors.WithStack(err) } if err = storage.Write(url, tag, buf); err != nil { return errors.WithStack(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 errors.WithMessage(err, "could not render tag feed page") } buf.Reset() if _, err := feed.WriteTo(buf); err != nil { return errors.WithStack(err) } if err := storage.Write(path.Join("/tags", tag, "atom.xml"), title, buf); err != nil { return errors.WithStack(err) } } log.Debug("rendering list page") buf.Reset() if err := templates.ListPage(siteSettings, templates.ListPageVars{ Posts: cc.Posts, }).Render(buf); err != nil { return errors.WithStack(err) } if err := storage.Write(path.Join("/", postDir)+"/", "Posts", buf); err != nil { return errors.WithStack(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 errors.WithMessage(err, "could not render feed") } buf.Reset() if _, err := feed.WriteTo(buf); err != nil { return errors.WithStack(err) } if err := storage.Write("/atom.xml", config.Title, buf); err != nil { return errors.WithStack(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 errors.WithMessagef(err, "could not render template file %s", filename) } if err := storage.Write("/"+filename, "", buf); err != nil { return errors.WithStack(err) } } for _, post := range cc.Pages { buf.Reset() log.Debug("rendering page", "source", post.Input, "path", post.URL) if post.URL == "/" { if err := templates.Homepage(siteSettings, templates.HomepageVars{ Email: config.Email, Me: config.RelMe, Posts: cc.Posts, }, post).Render(buf); err != nil { return errors.WithStack(err) } } else { if err := templates.Page(siteSettings, post).Render(buf); err != nil { return errors.WithStack(err) } } file := storage.NewFileFromPost(post) file.ContentType = "text/html; charset=utf-8" if err := storage.WriteFile(file, buf); err != nil { return errors.WithStack(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 errors.WithStack(err) } log.Debug("rendering sitemap") buf.Reset() if _, err := sitemap.WriteTo(buf); err != nil { return errors.WithStack(err) } if err := storage.Write("/sitemap.xml", "sitemap", buf); err != nil { return errors.WithStack(err) } log.Debug("rendering robots.txt") buf.Reset() rob, err := template.RenderRobotsTXT(config) if err != nil { return errors.WithStack(err) } buf.Reset() if _, err := io.Copy(buf, rob); err != nil { return errors.WithStack(err) } if err := storage.Write("/robots.txt", "", buf); err != nil { return errors.WithStack(err) } for _, sf := range cc.StaticFiles { src := joinSource(sf) log.Debug("copying static file", "sf", sf, "src", src) err = copyFile(storage, src, sf) if err != nil { return errors.WithStack(err) } } return nil } func BuildSite(options *Options, cfg *config.Config, log *log.Logger) errors.E { if cfg == nil { return errors.New("config is nil") } return build(options, cfg, log) }