internal/feed/writer/atom10/atom10.go

// Package atom10 implements an Atom 1.0 feed writer. Atom's slightly
// stricter schema actually makes this easier than RSS; the tilstream
// documentation recommends Atom where the reader supports it. See
// mercemay.top/src/tilstream/ for the feed architecture.
package atom10

import (
	"encoding/xml"
	"fmt"
	"io"
	"time"

	"mercemay.top/src/tilstream/internal/feed"
)

// Feed holds channel-level metadata.
type Feed struct {
	Title    string
	Subtitle string
	ID       string
	SelfLink string
	AltLink  string
	Updated  time.Time
	Author   Person
	Rights   string
}

// Person is an atom:author or atom:contributor.
type Person struct {
	Name  string
	Email string
	URI   string
}

// Writer serializes Atom feeds.
type Writer struct {
	Indent bool
}

// NewWriter returns a writer with indentation enabled.
func NewWriter() *Writer { return &Writer{Indent: true} }

// Write emits the feed to w.
func (wr *Writer) Write(w io.Writer, f Feed, entries []feed.Item) error {
	if _, err := io.WriteString(w, xml.Header); err != nil {
		return err
	}
	doc := feedXML{
		XMLNS:    "http://www.w3.org/2005/Atom",
		Title:    f.Title,
		Subtitle: f.Subtitle,
		ID:       f.ID,
		Updated:  formatRFC3339(f.Updated),
		Rights:   f.Rights,
		Author: author{
			Name:  f.Author.Name,
			Email: f.Author.Email,
			URI:   f.Author.URI,
		},
		Links: []link{
			{Rel: "self", Type: "application/atom+xml", Href: f.SelfLink},
			{Rel: "alternate", Type: "text/html", Href: f.AltLink},
		},
	}
	for _, it := range entries {
		doc.Entries = append(doc.Entries, toEntry(it))
	}
	enc := xml.NewEncoder(w)
	if wr.Indent {
		enc.Indent("", "  ")
	}
	if err := enc.Encode(doc); err != nil {
		return fmt.Errorf("atom10: %w", err)
	}
	if wr.Indent {
		_, _ = io.WriteString(w, "\n")
	}
	return nil
}

type feedXML struct {
	XMLName  xml.Name `xml:"feed"`
	XMLNS    string   `xml:"xmlns,attr"`
	Title    string   `xml:"title"`
	Subtitle string   `xml:"subtitle,omitempty"`
	Links    []link   `xml:"link"`
	ID       string   `xml:"id"`
	Updated  string   `xml:"updated"`
	Author   author   `xml:"author"`
	Rights   string   `xml:"rights,omitempty"`
	Entries  []entry  `xml:"entry"`
}

type link struct {
	Rel  string `xml:"rel,attr,omitempty"`
	Type string `xml:"type,attr,omitempty"`
	Href string `xml:"href,attr"`
}

type author struct {
	Name  string `xml:"name"`
	Email string `xml:"email,omitempty"`
	URI   string `xml:"uri,omitempty"`
}

type entry struct {
	Title     string    `xml:"title"`
	Link      link      `xml:"link"`
	ID        string    `xml:"id"`
	Updated   string    `xml:"updated"`
	Published string    `xml:"published,omitempty"`
	Summary   string    `xml:"summary,omitempty"`
	Content   content   `xml:"content"`
	Category  []catXML  `xml:"category,omitempty"`
}

type catXML struct {
	Term string `xml:"term,attr"`
}

type content struct {
	Type string `xml:"type,attr"`
	Body string `xml:",chardata"`
}

func toEntry(it feed.Item) entry {
	cats := make([]catXML, 0, len(it.Categories))
	for _, c := range it.Categories {
		cats = append(cats, catXML{Term: c})
	}
	return entry{
		Title:     it.Title,
		Link:      link{Rel: "alternate", Type: "text/html", Href: it.Link},
		ID:        it.GUID,
		Updated:   formatRFC3339(it.Updated),
		Published: formatRFC3339(it.Published),
		Summary:   it.Summary,
		Content:   content{Type: "html", Body: it.Content},
		Category:  cats,
	}
}

func formatRFC3339(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format(time.RFC3339)
}