package content import ( "bytes" "context" "io" "os" "path" "path/filepath" "slices" "strings" "time" "go.alanpearce.eu/x/log" "github.com/adrg/frontmatter" mapset "github.com/deckarep/golang-set/v2" fences "github.com/stefanfritsch/goldmark-fences" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" htmlrenderer "github.com/yuin/goldmark/renderer/html" "gitlab.com/tozd/go/errors" ) type PostMatter struct { Date time.Time Description string Title string Taxonomies struct { Tags []string } } type Post struct { Input string Output string Basename string URL string *PostMatter content []byte } type Config struct { Root string PostDir string } type Collection struct { config *Config Posts []Post Tags mapset.Set[string] } var markdown = goldmark.New( goldmark.WithRendererOptions( htmlrenderer.WithUnsafe(), ), goldmark.WithExtensions( extension.GFM, extension.Footnote, extension.Typographer, &fences.Extender{}, ), ) var postOutputReplacer = strings.NewReplacer( "index.md", "index.html", ".md", "/index.html", ) var postURLReplacer = strings.NewReplacer( "index.md", "", ".md", "/", ) func (cc *Collection) GetPost(filename string) (*Post, error) { fp := filepath.Join(cc.config.Root, cc.config.PostDir, filename) url := path.Join("/", cc.config.PostDir, postURLReplacer.Replace(filename)) + "/" post := &Post{ Input: fp, Output: path.Join(cc.config.PostDir, postOutputReplacer.Replace(filename)), Basename: filepath.Base(url), URL: url, PostMatter: &PostMatter{}, } err := parse(fp, post) if err != nil { return nil, err } return post, nil } var pageOutputReplacer = strings.NewReplacer( "index.md", "index.html", ".md", ".html", ) 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)) post := &Post{ Input: fp, Output: pageOutputReplacer.Replace(filename), Basename: filepath.Base(url), URL: url, PostMatter: &PostMatter{}, } err := parse(fp, post) if err != nil { return nil, err } 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 NewContentCollection(config *Config, log *log.Logger) (*Collection, error) { cc := &Collection{ Posts: []Post{}, Tags: mapset.NewSet[string](), config: config, } log.Debug("reading posts", "root", config.Root, "input_dir", config.PostDir) subdir := filepath.Join(config.Root, config.PostDir) files, err := os.ReadDir(subdir) if err != nil { return nil, errors.WithMessagef(err, "could not read post directory %s", subdir) } for _, f := range files { fn := f.Name() if !f.IsDir() && path.Ext(fn) == ".md" { log.Debug("reading post", "post", fn) post, err := cc.GetPost(fn) if err != nil { return nil, err } for _, tag := range post.PostMatter.Taxonomies.Tags { cc.Tags.Add(strings.ToLower(tag)) } cc.Posts = append(cc.Posts, *post) } } slices.SortFunc(cc.Posts, func(a, b Post) int { return b.Date.Compare(a.Date) }) return cc, nil }