// Package feed also produces an Atom 1.0 feed alongside the RSS one. I added
// Atom after someone on the mailing list pointed out NetNewsWire preferred it
// for category handling. Keeps the same Post shape as rss.go.
package feed
import (
"encoding/xml"
"fmt"
"net/url"
"os"
"strings"
"time"
"mercemay.top/src/tilstream/internal/render"
)
const atomNS = "http://www.w3.org/2005/Atom"
type atomFeed struct {
XMLName xml.Name `xml:"feed"`
XMLNS string `xml:"xmlns,attr"`
ID string `xml:"id"`
Title string `xml:"title"`
Subtitle string `xml:"subtitle,omitempty"`
Updated string `xml:"updated"`
Link []atomLink `xml:"link"`
Author atomPerson `xml:"author"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
}
type atomPerson struct {
Name string `xml:"name"`
Email string `xml:"email,omitempty"`
URI string `xml:"uri,omitempty"`
}
type atomEntry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Published string `xml:"published"`
Link atomLink `xml:"link"`
Summary string `xml:"summary,omitempty"`
Content atomContent `xml:"content"`
Categories []atomCat `xml:"category,omitempty"`
}
type atomContent struct {
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
type atomCat struct {
Term string `xml:"term,attr"`
}
// WriteAtom renders an Atom 1.0 feed for the given posts. baseURL becomes the
// <id> and self-link of the feed; the per-entry id is baseURL + slug so
// readers can dedupe if the post moves under a different tag.
func WriteAtom(dest string, posts []render.Post, baseURL, authorName, authorEmail string) error {
base := strings.TrimSuffix(baseURL, "/")
feed := atomFeed{
XMLNS: atomNS,
ID: base + "/",
Title: "TIL",
Subtitle: "Today I Learned notes",
Updated: newestMtime(posts).UTC().Format(time.RFC3339),
Link: []atomLink{
{Href: base + "/atom.xml", Rel: "self", Type: "application/atom+xml"},
{Href: base + "/", Rel: "alternate", Type: "text/html"},
},
Author: atomPerson{Name: authorName, Email: authorEmail, URI: base + "/"},
}
for _, p := range posts {
link, err := url.JoinPath(base, p.Slug+".html")
if err != nil {
return fmt.Errorf("atom: join url for %s: %w", p.Slug, err)
}
entry := atomEntry{
ID: link,
Title: p.Title,
Updated: p.Date.UTC().Format(time.RFC3339),
Published: p.Date.UTC().Format(time.RFC3339),
Link: atomLink{Href: link, Rel: "alternate", Type: "text/html"},
Summary: render.Summary(p.Body),
Content: atomContent{Type: "html", Body: render.HTML(p.Body)},
}
for _, t := range p.Tags {
entry.Categories = append(entry.Categories, atomCat{Term: t})
}
feed.Entries = append(feed.Entries, entry)
}
return writeXML(dest, feed)
}
func newestMtime(posts []render.Post) time.Time {
var newest time.Time
for _, p := range posts {
if p.Date.After(newest) {
newest = p.Date
}
}
if newest.IsZero() {
newest = time.Now()
}
return newest
}
func writeXML(dest string, v any) error {
out, err := xml.MarshalIndent(v, "", " ")
if err != nil {
return err
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(xml.Header); err != nil {
return err
}
if _, err := f.Write(out); err != nil {
return err
}
_, err = f.WriteString("\n")
return err
}