package content import ( "bytes" "context" "io" "io/fs" "os" "path" "path/filepath" "slices" "strings" "time" "go.alanpearce.eu/homestead/internal/markdown" "go.alanpearce.eu/homestead/internal/vcs" "go.alanpearce.eu/x/log" "github.com/adrg/frontmatter" mapset "github.com/deckarep/golang-set/v2" "gitlab.com/tozd/go/errors" ) var SkipList = []string{ "LICENSE", "taplo.toml", } type PostMatter struct { Date time.Time Description string Title string Taxonomies struct { Tags []string } } type Post struct { Input string Basename string URL string Commits []*vcs.Commit *PostMatter content []byte } type Config struct { Root string PostDir string Repo *vcs.Repository } type Collection struct { config *Config log *log.Logger Posts []*Post Pages []*Post StaticFiles []string Tags mapset.Set[string] } var postURLReplacer = strings.NewReplacer( "index.md", "", ".md", "/", ) func (cc *Collection) GetPost(filename string) (*Post, error) { fp := filepath.Join(cc.config.Root, filename) url := path.Join("/", postURLReplacer.Replace(filename)) + "/" cs, err := cc.config.Repo.GetFileLog(filename) if err != nil { return nil, errors.WithMessagef(err, "could not get commit log for file %s", filename) } post := &Post{ Input: filename, Basename: filepath.Base(url), URL: url, PostMatter: &PostMatter{}, Commits: cs, } err = parse(fp, post) if err != nil { return nil, err } return post, nil } var pageURLReplacer = strings.NewReplacer( "index.md", "", ".md", "", ) func (cc *Collection) GetPage(filename string) (*Post, error) { fp := filepath.Join(cc.config.Root, filename) url := path.Join("/", pageURLReplacer.Replace(filename)) cs, err := cc.config.Repo.GetFileLog(filename) if err != nil { return nil, errors.WithMessagef(err, "could not get commit log for file %s", filename) } post := &Post{ Input: filename, Basename: filepath.Base(url), URL: url, PostMatter: &PostMatter{}, Commits: cs, } err = parse(fp, post) if err != nil { return nil, err } if post.Date.IsZero() && len(cs) > 1 { post.Date = cs[0].Date } return post, nil } func parse(fp string, post *Post) error { content, err := os.Open(fp) if err != nil { return errors.WithMessagef(err, "could not open post %s", fp) } defer content.Close() post.content, err = frontmatter.Parse(content, post.PostMatter) if err != nil { return errors.WithMessagef( err, "could not parse front matter of post %s", fp, ) } return nil } // implements templ.Component func (p *Post) Render(_ context.Context, w io.Writer) error { return markdown.Convert(p.content, w) } func (p *Post) RenderString() (string, error) { var buf bytes.Buffer if err := p.Render(context.Background(), &buf); err != nil { return "", errors.WithMessage(err, "could not convert markdown content") } return buf.String(), nil } func (cc *Collection) HandleFile(filename string, d fs.DirEntry) error { switch { case strings.HasPrefix(filename, ".") && filename != "." && !strings.HasPrefix(filename, ".well-known"): cc.log.Debug("skipping", "filename", filename, "is_dir", d.Type().IsDir()) if d.Type().IsDir() { return fs.SkipDir } case !d.Type().IsDir(): if filepath.Ext(filename) == ".md" { if strings.HasPrefix(filename, cc.config.PostDir) { post, err := cc.GetPost(filename) if err != nil { return err } for _, tag := range post.PostMatter.Taxonomies.Tags { cc.Tags.Add(strings.ToLower(tag)) } cc.Posts = append(cc.Posts, post) } else { page, err := cc.GetPage(filename) if err != nil { return err } cc.Pages = append(cc.Pages, page) } } else if !slices.Contains(SkipList, filename) { cc.StaticFiles = append(cc.StaticFiles, filename) } } return nil } func NewContentCollection(config *Config, log *log.Logger) (*Collection, error) { cc := &Collection{ Posts: []*Post{}, Tags: mapset.NewSet[string](), Pages: []*Post{}, StaticFiles: []string{}, config: config, log: log, } err := filepath.WalkDir(config.Root, func(filename string, d fs.DirEntry, err error) error { if err != nil { return err } filename, err = filepath.Rel(config.Root, filename) if err != nil { return err } log.Debug("walking", "filename", filename) return cc.HandleFile(filename, d) }) slices.SortFunc(cc.Posts, func(a, b *Post) int { return b.Date.Compare(a.Date) }) if err != nil { return nil, errors.WithMessage(err, "could not walk directory") } return cc, nil }