internal/feed/writer/jsonfeed/jsonfeed.go

// Package jsonfeed writes JSON Feed 1.1 documents, as specified at
// jsonfeed.org. I added this after realizing the JSON Feed reader I use
// most often didn't respect RSS 2.0 GUIDs.
//
// mercemay.top/src/tilstream/ has an overview of all three writers.
package jsonfeed

import (
	"encoding/json"
	"io"
	"time"

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

// Feed is a top-level JSON Feed document.
type Feed struct {
	Version     string  `json:"version"`
	Title       string  `json:"title"`
	HomePageURL string  `json:"home_page_url,omitempty"`
	FeedURL     string  `json:"feed_url,omitempty"`
	Description string  `json:"description,omitempty"`
	Language    string  `json:"language,omitempty"`
	Authors     []Author `json:"authors,omitempty"`
	Icon        string  `json:"icon,omitempty"`
	Favicon     string  `json:"favicon,omitempty"`
	Items       []Item  `json:"items"`
}

// Author mirrors the spec's author object.
type Author struct {
	Name   string `json:"name,omitempty"`
	URL    string `json:"url,omitempty"`
	Avatar string `json:"avatar,omitempty"`
}

// Item is a single feed entry.
type Item struct {
	ID            string   `json:"id"`
	URL           string   `json:"url,omitempty"`
	Title         string   `json:"title,omitempty"`
	ContentHTML   string   `json:"content_html,omitempty"`
	ContentText   string   `json:"content_text,omitempty"`
	Summary       string   `json:"summary,omitempty"`
	DatePublished string   `json:"date_published,omitempty"`
	DateModified  string   `json:"date_modified,omitempty"`
	Authors       []Author `json:"authors,omitempty"`
	Tags          []string `json:"tags,omitempty"`
}

// Writer serializes JSON Feeds.
type Writer struct {
	Pretty bool
}

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

// Write emits the feed to w.
func (wr *Writer) Write(w io.Writer, f Feed, items []feed.Item) error {
	f.Version = "https://jsonfeed.org/version/1.1"
	if f.Items == nil {
		f.Items = make([]Item, 0, len(items))
	}
	for _, it := range items {
		f.Items = append(f.Items, FromFeedItem(it))
	}
	enc := json.NewEncoder(w)
	if wr.Pretty {
		enc.SetIndent("", "  ")
	}
	enc.SetEscapeHTML(false)
	return enc.Encode(f)
}

// FromFeedItem converts our internal feed.Item into a JSON Feed Item.
func FromFeedItem(it feed.Item) Item {
	return Item{
		ID:            it.GUID,
		URL:           it.Link,
		Title:         it.Title,
		ContentHTML:   it.Content,
		Summary:       it.Summary,
		DatePublished: formatTime(it.Published),
		DateModified:  formatTime(it.Updated),
		Tags:          append([]string(nil), it.Categories...),
		Authors:       authorsFromName(it.Author),
	}
}

func authorsFromName(name string) []Author {
	if name == "" {
		return nil
	}
	return []Author{{Name: name}}
}

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

// Decode reads a JSON Feed document from r. Useful for round-trip tests.
func Decode(r io.Reader) (Feed, error) {
	var f Feed
	if err := json.NewDecoder(r).Decode(&f); err != nil {
		return Feed{}, err
	}
	return f, nil
}