package builder import ( "context" "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) error { buf := new(buffer.Buffer) sf, err := os.Open(src) if err != nil { return err } defer sf.Close() buf.Reset() if _, err := io.Copy(buf, sf); err != nil { return err } if err := storage.Write("/"+rel, buf); err != nil { return err } return nil } func build( options *Options, config *config.Config, log *log.Logger, ) error { ctx := context.TODO() buf := new(buffer.Buffer) joinSource := joinSourcePath(options.Source) storage := options.Storage log.Debug("reading posts", "source", options.Source) cc, err := content.NewContentCollection(&content.Config{ Root: options.Source, PostDir: "post", Repo: options.Repo, }, log.Named("content")) if err != nil { return 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(config, post).Render(ctx, buf); err != nil { return errors.WithMessage(err, "could not render post") } if err := storage.WritePost(post, buf); err != nil { return err } } log.Debug("rendering tags list") buf.Reset() if err := templates.TagsPage(config, "tags", mapset.Sorted(cc.Tags), "/tags").Render(ctx, buf); err != nil { return err } if err := storage.Write("/tags/", buf); err != nil { return 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(config, tag, matchingPosts, url).Render(ctx, buf); err != nil { return err } if err = storage.Write(url, buf); err != nil { return err } sitemap.AddPath(url, matchingPosts[0].Date) log.Debug("rendering tags feed", "tag", tag) feed, err := template.RenderFeed( fmt.Sprintf("%s - %s", config.Title, tag), 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 err } if err := storage.Write(path.Join("/tags", tag, "atom.xml"), buf); err != nil { return err } } log.Debug("rendering list page") buf.Reset() if err := templates.ListPage(config, cc.Posts, "/post").Render(ctx, buf); err != nil { return err } if err := storage.Write("/post/", buf); err != nil { return err } sitemap.AddPath("/post/", 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 err } if err := storage.Write("/atom.xml", buf); err != nil { return 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 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(config, cc.Posts, post).Render(ctx, buf); err != nil { return err } } else { if err := templates.Page(config, post).Render(ctx, buf); err != nil { return err } } file := storage.NewFileFromPost(post) file.ContentType = "text/html; charset=utf-8" if err := storage.WriteFile(file, buf); err != nil { return 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{}) err = buf.SeekStart() if err != nil { return err } log.Debug("rendering sitemap") buf.Reset() if _, err := sitemap.WriteTo(buf); err != nil { return err } if err := storage.Write("/sitemap.xml", buf); err != nil { return err } log.Debug("rendering robots.txt") rob, err := template.RenderRobotsTXT(config) if err != nil { return err } buf.Reset() if _, err := io.Copy(buf, rob); err != nil { return err } if err := storage.Write("/robots.txt", buf); err != nil { return 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 err } } return nil } func BuildSite(options *Options, cfg *config.Config, log *log.Logger) error { if cfg == nil { return errors.New("config is nil") } cfg.InjectLiveReload = options.Development templates.Init() return build(options, cfg, log) }