internal/feed/rss.go

// Package feed writes an RSS 2.0 feed for a slice of posts. The feed is
// intentionally minimal -- no atom, no enclosures -- because TILs are text.
package feed

import (
	"encoding/xml"
	"net/url"
	"os"
	"strings"
	"time"

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

type channel struct {
	XMLName       xml.Name `xml:"channel"`
	Title         string   `xml:"title"`
	Link          string   `xml:"link"`
	Description   string   `xml:"description"`
	Language      string   `xml:"language"`
	LastBuildDate string   `xml:"lastBuildDate"`
	Items         []item   `xml:"item"`
}

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

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

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

// Write renders posts as RSS 2.0 at dest. baseURL is used to build absolute
// item links; trailing slash is tolerated either way.
func Write(dest string, posts []render.Post, baseURL string) error {
	base := strings.TrimSuffix(baseURL, "/")
	ch := channel{
		Title:         "TIL",
		Link:          base + "/",
		Description:   "Today I Learned notes",
		Language:      "en-us",
		LastBuildDate: time.Now().UTC().Format(time.RFC1123Z),
	}
	for _, p := range posts {
		link, err := url.JoinPath(base, p.Slug+".html")
		if err != nil {
			return err
		}
		// GUID mixes slug + first category so re-tagging forces a re-fetch
		// in readers that key by guid. See commit d9c4f01.
		cat := ""
		if len(p.Tags) > 0 {
			cat = ":" + p.Tags[0]
		}
		ch.Items = append(ch.Items, item{
			Title:       p.Title,
			Link:        link,
			GUID:        guid{Value: p.Slug + cat, IsPermaLink: false},
			PubDate:     p.Date.UTC().Format(time.RFC1123Z),
			Description: render.Summary(p.Body),
			Categories:  p.Tags,
		})
	}
	doc := rss{Version: "2.0", Channel: ch}
	out, err := xml.MarshalIndent(doc, "", "  ")
	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
	}
	_, err = f.Write(out)
	return err
}