package builder import ( "fmt" "os" "path" "path/filepath" "slices" "time" "alin.ovh/gomponents" "alin.ovh/x/log" "github.com/Southclaws/fault" "github.com/Southclaws/fault/fmsg" mapset "github.com/deckarep/golang-set/v2" "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/config" "alin.ovh/homestead/shared/storage" "alin.ovh/homestead/shared/vcs" ) 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:"-"` } 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 { sf, err := os.Open(src) if err != nil { return fault.Wrap(err) } defer sf.Close() if err := storage.Write("/"+rel, "", sf); err != nil { return fault.Wrap(err) } return nil } func build( options *Options, config *config.Config, log *log.Logger, ) error { 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) if err := stor.WritePost(post, ctemplates.PostPage(siteSettings, post)); err != nil { return fault.Wrap(err) } } log.Debug("rendering tags list") err = stor.Write("/tags/", "Tags", ctemplates.TagsPage(siteSettings, ctemplates.TagsPageVars{ Title: "Tags", Tags: mapset.Sorted(cc.Tags), }), ) if 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) + "/" err = stor.Write(url, tag, ctemplates.TagPage(siteSettings, ctemplates.TagPageVars{ Tag: tag, Posts: matchingPosts, }), ) if 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")) } file := &storage.File{ Title: title, LastModified: matchingPosts[0].Date, Path: path.Join("/tags", tag, "atom.xml"), FSPath: path.Join("/tags", tag, "atom.xml"), } if err := stor.WriteFile(file, feed); err != nil { return fault.Wrap(err) } } log.Debug("rendering list page") listPage := ctemplates.ListPage(siteSettings, ctemplates.ListPageVars{ Posts: cc.Posts, }) if err := stor.Write(path.Join("/", postDir)+"/", "Posts", listPage); 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")) } file := &storage.File{ Title: config.Title, LastModified: cc.Posts[0].Date, Path: "/atom.xml", FSPath: "/atom.xml", } if err := stor.WriteFile(file, feed); err != nil { return fault.Wrap(err) } tplFiles, err := template.ListFiles() if err != nil { return fault.Wrap(err) } for _, e := range tplFiles { filename := e.Name() log.Debug("rendering template file", "filename", filename) f, err := templates.Files.Open(filename) if err != nil { return fault.Wrap(err) } if err := stor.Write("/"+filename, "", templates.MakeWriterTo(f)); err != nil { return fault.Wrap(err) } } for _, post := range cc.Pages { log.Debug("rendering page", "source", post.Input, "path", post.URL) var page gomponents.Node if post.URL == "/" { page = ctemplates.Homepage(siteSettings, ctemplates.HomepageVars{ Email: config.PublicEmail, Me: config.RelMe, Posts: cc.Posts, }, post) } else { page = ctemplates.Page(siteSettings, post) } file := stor.NewFileFromPost(post) if err := stor.WriteFile(file, page.(gomponents.NodeWriter)); 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{}) log.Debug("rendering sitemap") if err := stor.Write("/sitemap.xml", "sitemap", sitemap); err != nil { return fault.Wrap(err) } log.Debug("rendering robots.txt") robots, err := template.RenderRobotsTXT(config.BaseURL) if err != nil { return fault.Wrap(err) } if err := stor.Write("/robots.txt", "", robots); 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 { page := vtemplates.GoPackagePage(siteSettings, &config.Go, p) if err := stor.Write(fmt.Sprintf("/go/%s.html", p), siteSettings.Title, page); err != nil { return fault.Wrap(err) } } page := vtemplates.GoPackageListPage(siteSettings, &config.Go) if err := stor.Write("/go/", siteSettings.Title, page); 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) }