internal/feed/atom.go

// 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
}