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