// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package feeds renders the RSS 2.0, Atom 1.0, JSON Feed 1.1 and // sitemap documents for the public API. package feeds import ( "bytes" json "encoding/json/v2" "fmt" "html" "log/slog" "os" "strings" "time" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/post" ) // FeedItemLimit caps the number of items in a feed. const FeedItemLimit = 20 // SitemapURLLimit caps the URLs in one sitemap document: the sitemaps.org // protocol defines 50 000 as the maximum a single document may carry, so // a larger site truncates to the newest posts rather than shipping a // document consumers may refuse whole. const SitemapURLLimit = 50_000 // language returns the site language, defaulting to English. func language(site config.Site) string { if site.Language != "" { return site.Language } return DefaultLanguage } // DefaultLanguage is the feed language when [site].language is empty. const DefaultLanguage = "en" // RenderRSSFeed renders an RSS 2.0 XML feed for the given posts. // selfPath is the path of the feed being rendered, so a reader can see // which document it fetched. func RenderRSSFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string { items := make([]string, 0, min(len(posts), FeedItemLimit)) for _, p := range posts[:min(len(posts), FeedItemLimit)] { items = append(items, rssItem(p, baseURL)) } return fmt.Sprintf(` %s %s %s %s %s `, html.EscapeString(site.Title), html.EscapeString(baseURL), html.EscapeString(site.Description), html.EscapeString(language(site)), html.EscapeString(baseURL+selfPath), strings.Join(items, "\n")) } func rssItem(p *post.Post, baseURL string) string { permalink := baseURL + "/" + p.Slug() var extra strings.Builder if dateStr := p.DateString(); dateStr != "" { fmt.Fprintf(&extra, "\n %s", html.EscapeString(rfc822Date(dateStr))) } if creator := p.FediverseCreator(); creator != "" { fmt.Fprintf(&extra, "\n %s", html.EscapeString(creator)) } if lang := p.Lang(); lang != "" { fmt.Fprintf(&extra, "\n %s", html.EscapeString(lang)) } return fmt.Sprintf(` %s %s %s %s%s `, html.EscapeString(p.Title()), html.EscapeString(permalink), html.EscapeString(permalink), html.EscapeString(p.Excerpt()), extra.String()) } // rfc822Date formats a YYYY-MM-DD string as an RFC 822 timestamp, or // returns it unchanged when unparseable. func rfc822Date(value string) string { t, err := time.Parse("2006-01-02", value) if err != nil { return value } return t.UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT" } // rfc3339Date formats a YYYY-MM-DD string as RFC 3339 at UTC midnight, // or returns it unchanged when it cannot be parsed. func rfc3339Date(value string) string { t, err := time.Parse("2006-01-02", value) if err != nil { return value } return t.UTC().Format("2006-01-02T15:04:05-07:00") } // RenderAtomFeed renders an Atom 1.0 XML feed for the given posts. // selfPath is the path of the feed being rendered, so the rel=self link // names the document the client actually fetched rather than always the // site-wide feed. func RenderAtomFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string { title := html.EscapeString(site.Title) link := html.EscapeString(baseURL) feedURL := html.EscapeString(baseURL + selfPath) description := html.EscapeString(site.Description) updated := atomUpdated(posts) items := make([]string, 0, min(len(posts), FeedItemLimit)) for _, p := range posts[:min(len(posts), FeedItemLimit)] { items = append(items, atomEntry(p, baseURL)) } return fmt.Sprintf(` %s %s/ %s %s %s `, title, link, feedURL, link, html.EscapeString(updated), description, strings.Join(items, "\n")) } func atomUpdated(posts []*post.Post) string { for _, p := range posts { if dateStr := p.DateString(); dateStr != "" { return rfc3339Date(dateStr) } } // No post carries a date, so the feed has no update time of its own. // The epoch is used rather than the current time, because a document // that changes on every request defeats every cache in front of it. return "1970-01-01T00:00:00Z" } func atomEntry(p *post.Post, baseURL string) string { permalink := baseURL + "/" + p.Slug() updated, published := "", "" if dateStr := p.DateString(); dateStr != "" { updated = rfc3339Date(dateStr) published = updated } authorTag := "" if creator := p.FediverseCreator(); creator != "" { authorTag = fmt.Sprintf("\n %s", html.EscapeString(creator)) } langAttr := "" if lang := p.Lang(); lang != "" { langAttr = fmt.Sprintf(` xml:lang="%s"`, html.EscapeString(lang)) } return fmt.Sprintf(` %s %s %s %s %s%s `, langAttr, html.EscapeString(p.Title()), html.EscapeString(permalink), html.EscapeString(permalink), updated, published, html.EscapeString(p.Excerpt()), authorTag) } // JSONFeed is a JSON Feed 1.1 document. Empty optional fields are // omitted, matching the feed specification. type JSONFeed struct { Version string `json:"version"` Title string `json:"title"` HomePageURL string `json:"home_page_url"` FeedURL string `json:"feed_url"` Description string `json:"description"` Language string `json:"language"` Items []JSONItem `json:"items,omitempty"` } // JSONItem is one JSON Feed item. Empty optional fields are omitted. type JSONItem struct { ID string `json:"id"` URL string `json:"url"` Title string `json:"title"` ContentHTML string `json:"content_html"` Summary string `json:"summary,omitempty"` DatePublished string `json:"date_published,omitempty"` Tags []string `json:"tags,omitempty"` Authors []JSONAuthor `json:"authors,omitempty"` } // JSONAuthor is the author entry of a JSON Feed item. type JSONAuthor struct { Name string `json:"name"` } // RenderJSONFeed builds a JSON Feed 1.1 document for the given posts. // selfPath is the path of the feed being rendered, for feed_url. func RenderJSONFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) JSONFeed { limit := min(len(posts), FeedItemLimit) items := make([]JSONItem, 0, limit) for _, p := range posts[:limit] { items = append(items, jsonFeedItem(p, baseURL, site)) } return JSONFeed{ Version: "https://jsonfeed.org/version/1.1", Title: site.Title, HomePageURL: baseURL, FeedURL: baseURL + selfPath, Description: site.Description, Language: language(site), Items: items, } } func jsonFeedItem(p *post.Post, baseURL string, site config.Site) JSONItem { permalink := baseURL + "/" + p.Slug() htmlOut, err := p.HTML() if err != nil { // The item still goes out (a feed with a body-less entry beats a // feed that 500s for one bad post), but not silently: the detail // endpoint fails loudly for the same post, and the feed should // leave the same trace. slog.Warn("feeds: cannot render post for the JSON feed", "slug", p.Slug(), "error", err) htmlOut = "" } item := JSONItem{ ID: permalink, URL: permalink, Title: p.Title(), ContentHTML: htmlOut, Summary: p.Excerpt(), DatePublished: p.DateString(), Tags: p.Tags(), } author := p.FediverseCreator() if author == "" { author = site.FediverseCreator } if author != "" { item.Authors = []JSONAuthor{{Name: author}} } return item } // MarshalJSONFeed serialises a feed. encoding/json/v2 escapes only what // JSON requires, so the HTML inside content_html reaches the client as the // post was rendered rather than with every angle bracket escaped. func MarshalJSONFeed(feed JSONFeed) ([]byte, error) { var buf bytes.Buffer if err := json.MarshalWrite(&buf, feed, json.Deterministic(true)); err != nil { return nil, err } return buf.Bytes(), nil } // RenderSitemap renders an XML sitemap, preferring the file // modification time for . At most SitemapURLLimit URLs are // included, newest posts first (the caller lists them that way). func RenderSitemap(posts []*post.Post, baseURL string) string { var urls []string for _, p := range posts[:min(len(posts), SitemapURLLimit)] { entry := " " + html.EscapeString(baseURL+"/"+p.Slug()) + "" if lastmod := lastmodFor(p); lastmod != "" { entry += "" + html.EscapeString(lastmod) + "" } entry += "" urls = append(urls, entry) } return ` ` + strings.Join(urls, "\n") + ` ` } func lastmodFor(p *post.Post) string { if p.Path != "" { if info, err := os.Stat(p.Path); err == nil { return info.ModTime().UTC().Format("2006-01-02") } } return p.DateString() }