Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

301 lines
9.7 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>%s</title>
<link>%s</link>
<description>%s</description>
<language>%s</language>
<atom:link rel="self" type="application/rss+xml" href="%s"/>
%s
</channel>
</rss>`,
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 <pubDate>%s</pubDate>", html.EscapeString(rfc822Date(dateStr)))
}
if creator := p.FediverseCreator(); creator != "" {
fmt.Fprintf(&extra, "\n <dc:creator>%s</dc:creator>", html.EscapeString(creator))
}
if lang := p.Lang(); lang != "" {
fmt.Fprintf(&extra, "\n <dc:language>%s</dc:language>", html.EscapeString(lang))
}
return fmt.Sprintf(` <item>
<title>%s</title>
<link>%s</link>
<guid>%s</guid>
<description>%s</description>%s
</item>`,
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(`<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<link rel="self" type="application/atom+xml" href="%s"/>
<id>%s/</id>
<updated>%s</updated>
<subtitle>%s</subtitle>
%s
</feed>`,
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 <author><name>%s</name></author>", html.EscapeString(creator))
}
langAttr := ""
if lang := p.Lang(); lang != "" {
langAttr = fmt.Sprintf(` xml:lang="%s"`, html.EscapeString(lang))
}
return fmt.Sprintf(` <entry>
<title%s>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<id>%s</id>
<updated>%s</updated>
<published>%s</published>
<summary>%s</summary>%s
</entry>`,
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 <lastmod>. 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 := " <url><loc>" + html.EscapeString(baseURL+"/"+p.Slug()) + "</loc>"
if lastmod := lastmodFor(p); lastmod != "" {
entry += "<lastmod>" + html.EscapeString(lastmod) + "</lastmod>"
}
entry += "</url>"
urls = append(urls, entry)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
` + strings.Join(urls, "\n") + `
</urlset>`
}
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()
}