Monorepo for Tangled
tangled.org
1package blog
2
3import (
4 "bytes"
5 "cmp"
6 "embed"
7 "html/template"
8 "io"
9 "io/fs"
10 "os"
11 "slices"
12 "strings"
13 "time"
14
15 "github.com/adrg/frontmatter"
16 "github.com/gorilla/feeds"
17
18 "tangled.org/core/appview/pages"
19 "tangled.org/core/appview/pages/markup"
20 textension "tangled.org/core/appview/pages/markup/extension"
21)
22
23//go:embed posts
24var PostsFS embed.FS
25
26type Author struct {
27 Name string `yaml:"name"`
28 Email string `yaml:"email"`
29 Handle string `yaml:"handle"`
30}
31
32type PostMeta struct {
33 Slug string `yaml:"slug"`
34 Title string `yaml:"title"`
35 Subtitle string `yaml:"subtitle"`
36 Date string `yaml:"date"`
37 Authors []Author `yaml:"authors"`
38 Image string `yaml:"image"`
39 Draft bool `yaml:"draft"`
40}
41
42type Post struct {
43 Meta PostMeta
44 Body template.HTML
45}
46
47func (p Post) ParsedDate() time.Time {
48 t, _ := time.Parse("2006-01-02", p.Meta.Date)
49 return t
50}
51
52type indexParams struct {
53 LoggedInUser any
54 Posts []Post
55 Featured []Post
56}
57
58type postParams struct {
59 LoggedInUser any
60 Post Post
61}
62
63// Posts parses and returns all non-draft posts sorted newest-first.
64func Posts(postsDir string) ([]Post, error) {
65 return parsePosts(postsDir, false)
66}
67
68// AllPosts parses and returns all posts including drafts, sorted newest-first.
69func AllPosts(postsDir string) ([]Post, error) {
70 return parsePosts(postsDir, true)
71}
72
73func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) {
74 fsys := os.DirFS(postsDir)
75
76 entries, err := fs.ReadDir(fsys, ".")
77 if err != nil {
78 return nil, err
79 }
80
81 rctx := &markup.RenderContext{
82 RendererType: markup.RendererTypeDefault,
83 }
84 var posts []Post
85 for _, entry := range entries {
86 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
87 continue
88 }
89
90 data, err := fs.ReadFile(fsys, entry.Name())
91 if err != nil {
92 return nil, err
93 }
94
95 var meta PostMeta
96 rest, err := frontmatter.Parse(bytes.NewReader(data), &meta)
97 if err != nil {
98 return nil, err
99 }
100
101 if meta.Draft && !includeDrafts {
102 continue
103 }
104
105 htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes))
106
107 posts = append(posts, Post{
108 Meta: meta,
109 Body: template.HTML(htmlStr),
110 })
111 }
112
113 slices.SortFunc(posts, func(a, b Post) int {
114 return cmp.Compare(b.Meta.Date, a.Meta.Date)
115 })
116
117 return posts, nil
118}
119
120func AtomFeed(posts []Post, baseURL string) (string, error) {
121 feed := &feeds.Feed{
122 Title: "The Tangled Blog",
123 Link: &feeds.Link{Href: baseURL},
124 Author: &feeds.Author{Name: "Tangled"},
125 Created: time.Now(),
126 }
127
128 for _, p := range posts {
129 postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug
130
131 var authorName strings.Builder
132 for i, a := range p.Meta.Authors {
133 if i > 0 {
134 authorName.WriteString(" & ")
135 }
136 authorName.WriteString(a.Name)
137 }
138
139 feed.Items = append(feed.Items, &feeds.Item{
140 Title: p.Meta.Title,
141 Link: &feeds.Link{Href: postURL},
142 Description: p.Meta.Subtitle,
143 Author: &feeds.Author{Name: authorName.String()},
144 Created: p.ParsedDate(),
145 })
146 }
147
148 return feed.ToAtom()
149}
150
151// parseLayout builds a template set from the appview's fragments together with
152// the blog's own layouts, fragments, and the given page template (relative to
153// templatesDir). The result is ready to ExecuteTemplate with "layouts/blogbase".
154func parseLayout(p *pages.Pages, templatesDir, page string) (*template.Template, error) {
155 fragmentPaths, err := p.FragmentPaths()
156 if err != nil {
157 return nil, err
158 }
159
160 tpl, err := template.New("layouts/blogbase").
161 Funcs(p.FuncMap()).
162 ParseFS(p.EmbedFS(), fragmentPaths...)
163 if err != nil {
164 return nil, err
165 }
166
167 extraFS := os.DirFS(templatesDir)
168 err = fs.WalkDir(extraFS, ".", func(path string, d fs.DirEntry, err error) error {
169 if err != nil {
170 return err
171 }
172 if d.IsDir() || !strings.HasSuffix(path, ".html") {
173 return nil
174 }
175 // only the requested page, plus shared layouts and fragments
176 if path != page && !strings.Contains(path, "fragments/") && !strings.Contains(path, "layouts/") {
177 return nil
178 }
179 data, err := fs.ReadFile(extraFS, path)
180 if err != nil {
181 return err
182 }
183 if _, err = tpl.New(path).Parse(string(data)); err != nil {
184 return err
185 }
186 return nil
187 })
188 if err != nil {
189 return nil, err
190 }
191
192 return tpl, nil
193}
194
195// RenderIndex renders the blog index page to w.
196func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error {
197 tpl, err := parseLayout(p, templatesDir, "index.html")
198 if err != nil {
199 return err
200 }
201 var featured []Post
202 for _, post := range posts {
203 if post.Meta.Image != "" {
204 featured = append(featured, post)
205 if len(featured) == 3 {
206 break
207 }
208 }
209 }
210 return tpl.ExecuteTemplate(w, "layouts/blogbase", indexParams{Posts: posts, Featured: featured})
211}
212
213// RenderPost renders a single blog post page to w.
214func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error {
215 tpl, err := parseLayout(p, templatesDir, "post.html")
216 if err != nil {
217 return err
218 }
219 return tpl.ExecuteTemplate(w, "layouts/blogbase", postParams{Post: post})
220}