Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

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}