all repos — homestead @ 40f8ccfdf0c8a5855cdc211096f86459b28a261b

Code for my website

domain/content/builder/builder.go (view raw)

package builder

import (
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"slices"
	"time"

	"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/buffer"
	"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 {
	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",
			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",
		Path:         "/atom.xml",
		FSPath:       "/atom.xml",
	}
	if err := stor.WriteFile(file, buf); err != nil {
		return fault.Wrap(err)
	}

	tplFiles, err := template.ListFiles()
	if err != nil {
		return fault.Wrap(err)
	}

	for _, e := range tplFiles {
		filename := e.Name()

		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 copy 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)
}