internal/feed/writer/rss20/rss20.go

// Package rss20 writes RSS 2.0 feeds for tilstream sites. The package is
// deliberately small and does not claim to implement every RSS 2.0 feature;
// it implements exactly what this generator needs. See
// mercemay.top/src/tilstream/ for the feed architecture.
package rss20

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

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

// Channel holds the feed-level metadata. Items are supplied separately to
// keep the per-post payload small.
type Channel struct {
	Title       string
	Link        string
	Description string
	Language    string
	Generator   string
	Copyright   string
	LastBuild   time.Time
	Category    string
	Image       *Image
}

// Image is an optional channel image.
type Image struct {
	URL    string
	Title  string
	Link   string
	Width  int
	Height int
}

// Writer serializes RSS 2.0 feeds.
type Writer struct {
	Indent bool
}

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

// Write emits the feed XML to w.
func (wr *Writer) Write(w io.Writer, ch Channel, items []feed.Item) error {
	if _, err := io.WriteString(w, xml.Header); err != nil {
		return err
	}
	doc := document{
		Version: "2.0",
		Atom:    "http://www.w3.org/2005/Atom",
		Channel: channel{
			Title:       ch.Title,
			Link:        ch.Link,
			Description: cdata(ch.Description),
			Language:    ch.Language,
			Generator:   ch.Generator,
			Copyright:   ch.Copyright,
			LastBuild:   formatRFC1123Z(ch.LastBuild),
			Category:    ch.Category,
		},
	}
	if ch.Image != nil {
		doc.Channel.Image = &imageXML{
			URL:    ch.Image.URL,
			Title:  ch.Image.Title,
			Link:   ch.Image.Link,
			Width:  ch.Image.Width,
			Height: ch.Image.Height,
		}
	}
	for _, it := range items {
		doc.Channel.Items = append(doc.Channel.Items, toXMLItem(it))
	}
	enc := xml.NewEncoder(w)
	if wr.Indent {
		enc.Indent("", "  ")
	}
	if err := enc.Encode(doc); err != nil {
		return fmt.Errorf("rss20: %w", err)
	}
	if wr.Indent {
		_, _ = io.WriteString(w, "\n")
	}
	return nil
}

type document struct {
	XMLName xml.Name `xml:"rss"`
	Version string   `xml:"version,attr"`
	Atom    string   `xml:"xmlns:atom,attr"`
	Channel channel  `xml:"channel"`
}

type channel struct {
	Title       string      `xml:"title"`
	Link        string      `xml:"link"`
	Description cdata       `xml:"description"`
	Language    string      `xml:"language,omitempty"`
	Generator   string      `xml:"generator,omitempty"`
	Copyright   string      `xml:"copyright,omitempty"`
	LastBuild   string      `xml:"lastBuildDate,omitempty"`
	Category    string      `xml:"category,omitempty"`
	Image       *imageXML   `xml:"image,omitempty"`
	Items       []itemXML   `xml:"item"`
}

type imageXML struct {
	URL    string `xml:"url"`
	Title  string `xml:"title"`
	Link   string `xml:"link"`
	Width  int    `xml:"width,omitempty"`
	Height int    `xml:"height,omitempty"`
}

type itemXML struct {
	Title       string   `xml:"title"`
	Link        string   `xml:"link"`
	GUID        guid     `xml:"guid"`
	PubDate     string   `xml:"pubDate,omitempty"`
	Description cdata    `xml:"description"`
	Categories  []string `xml:"category,omitempty"`
	Author      string   `xml:"author,omitempty"`
}

type guid struct {
	IsPermaLink string `xml:"isPermaLink,attr"`
	Value       string `xml:",chardata"`
}

type cdata string

func (c cdata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
	type wrapper struct {
		Data string `xml:",cdata"`
	}
	return e.EncodeElement(wrapper{Data: string(c)}, start)
}

func toXMLItem(it feed.Item) itemXML {
	cat := make([]string, len(it.Categories))
	copy(cat, it.Categories)
	return itemXML{
		Title:       it.Title,
		Link:        it.Link,
		GUID:        guid{IsPermaLink: "true", Value: strings.TrimSpace(it.GUID)},
		PubDate:     formatRFC1123Z(it.Published),
		Description: cdata(it.Summary),
		Categories:  cat,
		Author:      it.Author,
	}
}

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