package blog import ( "bytes" "cmp" "html/template" "io" "io/fs" "os" "slices" "strings" "time" "github.com/adrg/frontmatter" "github.com/gorilla/feeds" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" textension "tangled.org/core/appview/pages/markup/extension" ) type Author struct { Name string `yaml:"name"` Email string `yaml:"email"` Handle string `yaml:"handle"` } type PostMeta struct { Slug string `yaml:"slug"` Title string `yaml:"title"` Subtitle string `yaml:"subtitle"` Date string `yaml:"date"` Authors []Author `yaml:"authors"` Image string `yaml:"image"` Draft bool `yaml:"draft"` } type Post struct { Meta PostMeta Body template.HTML } func (p Post) ParsedDate() time.Time { t, _ := time.Parse("2006-01-02", p.Meta.Date) return t } type indexParams struct { LoggedInUser any Posts []Post Featured []Post } type postParams struct { LoggedInUser any Post Post } // Posts parses and returns all non-draft posts sorted newest-first. func Posts(postsDir string) ([]Post, error) { return parsePosts(postsDir, false) } // AllPosts parses and returns all posts including drafts, sorted newest-first. func AllPosts(postsDir string) ([]Post, error) { return parsePosts(postsDir, true) } func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) { fsys := os.DirFS(postsDir) entries, err := fs.ReadDir(fsys, ".") if err != nil { return nil, err } rctx := &markup.RenderContext{ RendererType: markup.RendererTypeDefault, } var posts []Post for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { continue } data, err := fs.ReadFile(fsys, entry.Name()) if err != nil { return nil, err } var meta PostMeta rest, err := frontmatter.Parse(bytes.NewReader(data), &meta) if err != nil { return nil, err } if meta.Draft && !includeDrafts { continue } htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) posts = append(posts, Post{ Meta: meta, Body: template.HTML(htmlStr), }) } slices.SortFunc(posts, func(a, b Post) int { return cmp.Compare(b.Meta.Date, a.Meta.Date) }) return posts, nil } func AtomFeed(posts []Post, baseURL string) (string, error) { feed := &feeds.Feed{ Title: "the tangled blog", Link: &feeds.Link{Href: baseURL}, Author: &feeds.Author{Name: "Tangled"}, Created: time.Now(), } for _, p := range posts { postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug var authorName strings.Builder for i, a := range p.Meta.Authors { if i > 0 { authorName.WriteString(" & ") } authorName.WriteString(a.Name) } feed.Items = append(feed.Items, &feeds.Item{ Title: p.Meta.Title, Link: &feeds.Link{Href: postURL}, Description: p.Meta.Subtitle, Author: &feeds.Author{Name: authorName.String()}, Created: p.ParsedDate(), }) } return feed.ToAtom() } // RenderIndex renders the blog index page to w. func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error { tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html") if err != nil { return err } var featured []Post for _, post := range posts { if post.Meta.Image != "" { featured = append(featured, post) if len(featured) == 3 { break } } } return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured}) } // RenderPost renders a single blog post page to w. func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error { tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html") if err != nil { return err } return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post}) }