This repository has no description
1package blog
2
3import (
4 "bytes"
5 "cmp"
6 "html/template"
7 "io"
8 "io/fs"
9 "os"
10 "slices"
11 "strings"
12 "time"
13
14 "github.com/adrg/frontmatter"
15 "github.com/gorilla/feeds"
16
17 "tangled.org/core/appview/pages"
18 "tangled.org/core/appview/pages/markup"
19 textension "tangled.org/core/appview/pages/markup/extension"
20)
21
22type Author struct {
23 Name string `yaml:"name"`
24 Email string `yaml:"email"`
25 Handle string `yaml:"handle"`
26}
27
28type PostMeta struct {
29 Slug string `yaml:"slug"`
30 Title string `yaml:"title"`
31 Subtitle string `yaml:"subtitle"`
32 Date string `yaml:"date"`
33 Authors []Author `yaml:"authors"`
34 Image string `yaml:"image"`
35 Draft bool `yaml:"draft"`
36}
37
38type Post struct {
39 Meta PostMeta
40 Body template.HTML
41}
42
43func (p Post) ParsedDate() time.Time {
44 t, _ := time.Parse("2006-01-02", p.Meta.Date)
45 return t
46}
47
48type indexParams struct {
49 LoggedInUser any
50 Posts []Post
51 Featured []Post
52}
53
54type postParams struct {
55 LoggedInUser any
56 Post Post
57}
58
59// Posts parses and returns all non-draft posts sorted newest-first.
60func Posts(postsDir string) ([]Post, error) {
61 return parsePosts(postsDir, false)
62}
63
64// AllPosts parses and returns all posts including drafts, sorted newest-first.
65func AllPosts(postsDir string) ([]Post, error) {
66 return parsePosts(postsDir, true)
67}
68
69func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) {
70 fsys := os.DirFS(postsDir)
71
72 entries, err := fs.ReadDir(fsys, ".")
73 if err != nil {
74 return nil, err
75 }
76
77 rctx := &markup.RenderContext{
78 RendererType: markup.RendererTypeDefault,
79 Sanitizer: markup.NewSanitizer(),
80 }
81 var posts []Post
82 for _, entry := range entries {
83 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
84 continue
85 }
86
87 data, err := fs.ReadFile(fsys, entry.Name())
88 if err != nil {
89 return nil, err
90 }
91
92 var meta PostMeta
93 rest, err := frontmatter.Parse(bytes.NewReader(data), &meta)
94 if err != nil {
95 return nil, err
96 }
97
98 if meta.Draft && !includeDrafts {
99 continue
100 }
101
102 htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes))
103 sanitized := rctx.SanitizeDefault(htmlStr)
104
105 posts = append(posts, Post{
106 Meta: meta,
107 Body: template.HTML(sanitized),
108 })
109 }
110
111 slices.SortFunc(posts, func(a, b Post) int {
112 return cmp.Compare(b.Meta.Date, a.Meta.Date)
113 })
114
115 return posts, nil
116}
117
118func AtomFeed(posts []Post, baseURL string) (string, error) {
119 feed := &feeds.Feed{
120 Title: "the tangled blog",
121 Link: &feeds.Link{Href: baseURL},
122 Author: &feeds.Author{Name: "Tangled"},
123 Created: time.Now(),
124 }
125
126 for _, p := range posts {
127 postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug
128
129 var authorName string
130 for i, a := range p.Meta.Authors {
131 if i > 0 {
132 authorName += " & "
133 }
134 authorName += a.Name
135 }
136
137 feed.Items = append(feed.Items, &feeds.Item{
138 Title: p.Meta.Title,
139 Link: &feeds.Link{Href: postURL},
140 Description: p.Meta.Subtitle,
141 Author: &feeds.Author{Name: authorName},
142 Created: p.ParsedDate(),
143 })
144 }
145
146 return feed.ToAtom()
147}
148
149// RenderIndex renders the blog index page to w.
150func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error {
151 tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html")
152 if err != nil {
153 return err
154 }
155 var featured []Post
156 for _, post := range posts {
157 if post.Meta.Image != "" {
158 featured = append(featured, post)
159 if len(featured) == 3 {
160 break
161 }
162 }
163 }
164 return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured})
165}
166
167// RenderPost renders a single blog post page to w.
168func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error {
169 tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html")
170 if err != nil {
171 return err
172 }
173 return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post})
174}