all repos

rss-tools @ 71f9578bfe2969b6b22984c4264c8bf6c067e608

get rss feed from sources that(i need and) dont provide one
15 files changed, 554 insertions(+), 540 deletions(-)
refactor atom feed builder
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-05-23 15:55:44 +0300
Authored at: 2026-05-23 15:12:18 +0300
Change ID: kqrnswytroxstrsxkstqnrllylpktnso
Parent: 19e8c1c
D app/atom.go
···
        1
        
        -package app

      
        2
        
        -

      
        3
        
        -import (

      
        4
        
        -	"bytes"

      
        5
        
        -	"crypto/sha1"

      
        6
        
        -	"encoding/xml"

      
        7
        
        -	"fmt"

      
        8
        
        -	"io"

      
        9
        
        -	"net/http"

      
        10
        
        -	"strings"

      
        11
        
        -	"time"

      
        12
        
        -)

      
        13
        
        -

      
        14
        
        -const (

      
        15
        
        -	atomNamespace  = "http://www.w3.org/2005/Atom"

      
        16
        
        -	xhtmlNamespace = "http://www.w3.org/1999/xhtml"

      
        17
        
        -	defaultAuthor  = "rss-tools"

      
        18
        
        -)

      
        19
        
        -

      
        20
        
        -type AtomFeed struct {

      
        21
        
        -	XMLName  xml.Name     `xml:"feed"`

      
        22
        
        -	XMLNS    string       `xml:"xmlns,attr"`

      
        23
        
        -	Title    string       `xml:"title"`

      
        24
        
        -	ID       string       `xml:"id"`

      
        25
        
        -	Updated  string       `xml:"updated"`

      
        26
        
        -	Authors  []AtomPerson `xml:"author,omitempty"`

      
        27
        
        -	Subtitle string       `xml:"subtitle,omitempty"`

      
        28
        
        -	Entries  []AtomEntry  `xml:"entry"`

      
        29
        
        -}

      
        30
        
        -

      
        31
        
        -type AtomEntry struct {

      
        32
        
        -	Title   string      `xml:"title"`

      
        33
        
        -	ID      string      `xml:"id"`

      
        34
        
        -	Updated string      `xml:"updated"`

      
        35
        
        -	Links   []AtomLink  `xml:"link,omitempty"`

      
        36
        
        -	Content AtomContent `xml:"content"`

      
        37
        
        -}

      
        38
        
        -

      
        39
        
        -type AtomContent struct {

      
        40
        
        -	XMLName xml.Name `xml:"content"`

      
        41
        
        -	Type    string   `xml:"type,attr,omitempty"`

      
        42
        
        -	Value   string   `xml:",chardata"`

      
        43
        
        -}

      
        44
        
        -

      
        45
        
        -func (c AtomContent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {

      
        46
        
        -	contentType := c.Type

      
        47
        
        -	if contentType == "" {

      
        48
        
        -		contentType = "text"

      
        49
        
        -	}

      
        50
        
        -

      
        51
        
        -	start.Name = xml.Name{Local: "content"}

      
        52
        
        -	start.Attr = append(start.Attr, xml.Attr{

      
        53
        
        -		Name:  xml.Name{Local: "type"},

      
        54
        
        -		Value: contentType,

      
        55
        
        -	})

      
        56
        
        -

      
        57
        
        -	if err := e.EncodeToken(start); err != nil {

      
        58
        
        -		return err

      
        59
        
        -	}

      
        60
        
        -

      
        61
        
        -	if contentType == "xhtml" {

      
        62
        
        -		if err := validateXHTMLFragment(c.Value); err != nil {

      
        63
        
        -			return err

      
        64
        
        -		}

      
        65
        
        -

      
        66
        
        -		if err := e.Encode(xhtmlDiv{

      
        67
        
        -			XMLNS: xhtmlNamespace,

      
        68
        
        -			Inner: c.Value,

      
        69
        
        -		}); err != nil {

      
        70
        
        -			return err

      
        71
        
        -		}

      
        72
        
        -	} else {

      
        73
        
        -		if err := e.EncodeToken(xml.CharData([]byte(c.Value))); err != nil {

      
        74
        
        -			return err

      
        75
        
        -		}

      
        76
        
        -	}

      
        77
        
        -

      
        78
        
        -	if err := e.EncodeToken(start.End()); err != nil {

      
        79
        
        -		return err

      
        80
        
        -	}

      
        81
        
        -	return e.Flush()

      
        82
        
        -}

      
        83
        
        -

      
        84
        
        -type xhtmlDiv struct {

      
        85
        
        -	XMLName xml.Name `xml:"div"`

      
        86
        
        -	XMLNS   string   `xml:"xmlns,attr"`

      
        87
        
        -	Inner   string   `xml:",innerxml"`

      
        88
        
        -}

      
        89
        
        -

      
        90
        
        -func validateXHTMLFragment(fragment string) error {

      
        91
        
        -	wrapped := fmt.Sprintf(`<div xmlns="%s">%s</div>`, xhtmlNamespace, fragment)

      
        92
        
        -	dec := xml.NewDecoder(strings.NewReader(wrapped))

      
        93
        
        -	for {

      
        94
        
        -		_, err := dec.Token()

      
        95
        
        -		if err == io.EOF {

      
        96
        
        -			return nil

      
        97
        
        -		}

      
        98
        
        -		if err != nil {

      
        99
        
        -			return fmt.Errorf("invalid xhtml content: %w", err)

      
        100
        
        -		}

      
        101
        
        -	}

      
        102
        
        -}

      
        103
        
        -

      
        104
        
        -type AtomPerson struct {

      
        105
        
        -	Name string `xml:"name"`

      
        106
        
        -}

      
        107
        
        -

      
        108
        
        -type AtomLink struct {

      
        109
        
        -	Rel    string `xml:"rel,attr,omitempty"`

      
        110
        
        -	Type   string `xml:"type,attr,omitempty"`

      
        111
        
        -	Length string `xml:"length,attr,omitempty"`

      
        112
        
        -	Href   string `xml:"href,attr"`

      
        113
        
        -}

      
        114
        
        -

      
        115
        
        -type FeedEntry struct {

      
        116
        
        -	Title       string

      
        117
        
        -	ID          string

      
        118
        
        -	Links       []FeedLink

      
        119
        
        -	Content     string

      
        120
        
        -	ContentType string // "text", "html", or "xhtml"; defaults to "text"

      
        121
        
        -	Updated     time.Time

      
        122
        
        -}

      
        123
        
        -

      
        124
        
        -type FeedLink struct {

      
        125
        
        -	Rel    string

      
        126
        
        -	Type   string

      
        127
        
        -	Length string

      
        128
        
        -	Href   string

      
        129
        
        -}

      
        130
        
        -

      
        131
        
        -type FeedBuilder struct{ f AtomFeed }

      
        132
        
        -

      
        133
        
        -func NewFeed(title, id string) *FeedBuilder {

      
        134
        
        -	return &FeedBuilder{f: AtomFeed{

      
        135
        
        -		XMLNS:   atomNamespace,

      
        136
        
        -		Title:   title,

      
        137
        
        -		ID:      id,

      
        138
        
        -		Updated: time.Now().Format(time.RFC3339),

      
        139
        
        -		Authors: []AtomPerson{{Name: defaultAuthor}},

      
        140
        
        -	}}

      
        141
        
        -}

      
        142
        
        -

      
        143
        
        -func (f *FeedBuilder) WithSubtitle(subtitle string) *FeedBuilder {

      
        144
        
        -	f.f.Subtitle = subtitle

      
        145
        
        -	return f

      
        146
        
        -}

      
        147
        
        -

      
        148
        
        -func (f *FeedBuilder) WithAuthor(name string) *FeedBuilder {

      
        149
        
        -	name = strings.TrimSpace(name)

      
        150
        
        -	if name == "" {

      
        151
        
        -		return f

      
        152
        
        -	}

      
        153
        
        -	f.f.Authors = []AtomPerson{{Name: name}}

      
        154
        
        -	return f

      
        155
        
        -}

      
        156
        
        -

      
        157
        
        -func (f *FeedBuilder) WithUpdated(updated time.Time) *FeedBuilder {

      
        158
        
        -	if !updated.IsZero() {

      
        159
        
        -		f.f.Updated = updated.Format(time.RFC3339)

      
        160
        
        -	}

      
        161
        
        -	return f

      
        162
        
        -}

      
        163
        
        -

      
        164
        
        -func (f *FeedBuilder) Add(entry FeedEntry) *FeedBuilder {

      
        165
        
        -	if entry.Updated.IsZero() {

      
        166
        
        -		entry.Updated = time.Now()

      
        167
        
        -	}

      
        168
        
        -	if entry.ID == "" {

      
        169
        
        -		hash := sha1.Sum(fmt.Appendf(nil, "%s|%s|%s", entry.Title, entry.Content, entry.Updated.Format(time.RFC3339Nano)))

      
        170
        
        -		entry.ID = fmt.Sprintf("urn:sha1:%x", hash)

      
        171
        
        -	}

      
        172
        
        -

      
        173
        
        -	contentType := entry.ContentType

      
        174
        
        -	if contentType == "" {

      
        175
        
        -		contentType = "text"

      
        176
        
        -	}

      
        177
        
        -

      
        178
        
        -	links := make([]AtomLink, 0, len(entry.Links))

      
        179
        
        -	for _, link := range entry.Links {

      
        180
        
        -		if link.Href == "" {

      
        181
        
        -			continue

      
        182
        
        -		}

      
        183
        
        -		links = append(links, AtomLink(link))

      
        184
        
        -	}

      
        185
        
        -

      
        186
        
        -	f.f.Entries = append(f.f.Entries, AtomEntry{

      
        187
        
        -		Title:   entry.Title,

      
        188
        
        -		ID:      entry.ID,

      
        189
        
        -		Updated: entry.Updated.Format(time.RFC3339),

      
        190
        
        -		Links:   links,

      
        191
        
        -		Content: AtomContent{

      
        192
        
        -			Type:  contentType,

      
        193
        
        -			Value: entry.Content,

      
        194
        
        -		},

      
        195
        
        -	})

      
        196
        
        -

      
        197
        
        -	feedUpdated, err := time.Parse(time.RFC3339, f.f.Updated)

      
        198
        
        -	if err != nil || entry.Updated.After(feedUpdated) {

      
        199
        
        -		f.f.Updated = entry.Updated.Format(time.RFC3339)

      
        200
        
        -	}

      
        201
        
        -	return f

      
        202
        
        -}

      
        203
        
        -

      
        204
        
        -func (f *FeedBuilder) SetUpdated(updated time.Time) *FeedBuilder {

      
        205
        
        -	if !updated.IsZero() {

      
        206
        
        -		f.f.Updated = updated.Format(time.RFC3339)

      
        207
        
        -	}

      
        208
        
        -	return f

      
        209
        
        -}

      
        210
        
        -

      
        211
        
        -func (f *FeedBuilder) WriteTo(w io.Writer) error {

      
        212
        
        -	enc := xml.NewEncoder(w)

      
        213
        
        -	enc.Indent("", "  ")

      
        214
        
        -	return enc.Encode(f.f)

      
        215
        
        -}

      
        216
        
        -

      
        217
        
        -func (f *FeedBuilder) Bytes() ([]byte, error) {

      
        218
        
        -	var buf bytes.Buffer

      
        219
        
        -	if err := f.WriteTo(&buf); err != nil {

      
        220
        
        -		return nil, err

      
        221
        
        -	}

      
        222
        
        -	return buf.Bytes(), nil

      
        223
        
        -}

      
        224
        
        -

      
        225
        
        -func (f *FeedBuilder) Render(w http.ResponseWriter) error {

      
        226
        
        -	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")

      
        227
        
        -	return f.WriteTo(w)

      
        228
        
        -}

      
A app/atom/atom.go
···
        
        1
        +// Based on golang.org/x/tools/blog/atom.

      
        
        2
        +

      
        
        3
        +package atom

      
        
        4
        +

      
        
        5
        +import (

      
        
        6
        +	"encoding/xml"

      
        
        7
        +	"time"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +const xhtmlNamespace = "http://www.w3.org/1999/xhtml"

      
        
        11
        +

      
        
        12
        +// Feed represents an Atom feed.

      
        
        13
        +type Feed struct {

      
        
        14
        +	XMLName  xml.Name  `xml:"http://www.w3.org/2005/Atom feed"`

      
        
        15
        +	Title    string    `xml:"title"`

      
        
        16
        +	ID       string    `xml:"id"`

      
        
        17
        +	Link     []Link    `xml:"link,omitempty"`

      
        
        18
        +	Updated  TimeStr   `xml:"updated"`

      
        
        19
        +	Author   []*Person `xml:"author,omitempty"`

      
        
        20
        +	Subtitle string    `xml:"subtitle,omitempty"`

      
        
        21
        +	Entry    []*Entry  `xml:"entry"`

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +// Entry represents an Atom entry.

      
        
        25
        +type Entry struct {

      
        
        26
        +	Title     string    `xml:"title"`

      
        
        27
        +	ID        string    `xml:"id"`

      
        
        28
        +	Link      []Link    `xml:"link,omitempty"`

      
        
        29
        +	Published TimeStr   `xml:"published,omitempty"`

      
        
        30
        +	Updated   TimeStr   `xml:"updated"`

      
        
        31
        +	Author    []*Person `xml:"author,omitempty"`

      
        
        32
        +	Summary   *Text     `xml:"summary,omitempty"`

      
        
        33
        +	Content   *Text     `xml:"content,omitempty"`

      
        
        34
        +}

      
        
        35
        +

      
        
        36
        +// Link represents an Atom link.

      
        
        37
        +type Link struct {

      
        
        38
        +	Rel      string `xml:"rel,attr,omitempty"`

      
        
        39
        +	Href     string `xml:"href,attr"`

      
        
        40
        +	Type     string `xml:"type,attr,omitempty"`

      
        
        41
        +	HrefLang string `xml:"hreflang,attr,omitempty"`

      
        
        42
        +	Title    string `xml:"title,attr,omitempty"`

      
        
        43
        +	Length   uint   `xml:"length,attr,omitempty"`

      
        
        44
        +}

      
        
        45
        +

      
        
        46
        +// Person represents an Atom person.

      
        
        47
        +type Person struct {

      
        
        48
        +	Name     string `xml:"name"`

      
        
        49
        +	URI      string `xml:"uri,omitempty"`

      
        
        50
        +	Email    string `xml:"email,omitempty"`

      
        
        51
        +	InnerXML string `xml:",innerxml"`

      
        
        52
        +}

      
        
        53
        +

      
        
        54
        +// Text represents Atom text content.

      
        
        55
        +type Text struct {

      
        
        56
        +	Type string `xml:"type,attr"`

      
        
        57
        +	Body string `xml:",chardata"`

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +type TimeStr string

      
        
        61
        +

      
        
        62
        +func Time(t time.Time) TimeStr {

      
        
        63
        +	return TimeStr(t.Format(time.RFC3339))

      
        
        64
        +}

      
A app/atom/atom_test.go
···
        
        1
        +package atom

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"encoding/xml"

      
        
        5
        +	"net/http"

      
        
        6
        +	"net/http/httptest"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"olexsmir.xyz/x/is"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +func TestNewFeedDefaults(t *testing.T) {

      
        
        15
        +	feed := NewFeed("test", "feed-id")

      
        
        16
        +

      
        
        17
        +	is.Equal(t, "test", feed.Title)

      
        
        18
        +	is.Equal(t, "feed-id", feed.ID)

      
        
        19
        +	is.NotEqual(t, "", feed.Updated)

      
        
        20
        +	is.Equal(t, 1, len(feed.Author))

      
        
        21
        +	is.Equal(t, "rss-tools", feed.Author[0].Name)

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +func TestFeedAddAppendsEntry(t *testing.T) {

      
        
        25
        +	feed := NewFeed("test", "feed-id")

      
        
        26
        +	entry := &Entry{

      
        
        27
        +		Title:   "entry",

      
        
        28
        +		ID:      "entry-id",

      
        
        29
        +		Updated: Time(time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)),

      
        
        30
        +		Content: NewText("body", ""),

      
        
        31
        +	}

      
        
        32
        +	feed.Add(entry)

      
        
        33
        +

      
        
        34
        +	is.Equal(t, 1, len(feed.Entry))

      
        
        35
        +	is.Equal(t, "entry-id", feed.Entry[0].ID)

      
        
        36
        +}

      
        
        37
        +

      
        
        38
        +func TestFeedBytesAndWriteTo(t *testing.T) {

      
        
        39
        +	updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC)

      
        
        40
        +	feed := NewFeed("test", "feed-id").

      
        
        41
        +		Add(&Entry{

      
        
        42
        +			Title:   "entry",

      
        
        43
        +			ID:      "entry-id",

      
        
        44
        +			Updated: Time(updated),

      
        
        45
        +			Content: NewText("content", ""),

      
        
        46
        +		})

      
        
        47
        +

      
        
        48
        +	raw, err := feed.Bytes()

      
        
        49
        +	is.Err(t, err, nil)

      
        
        50
        +

      
        
        51
        +	var parsed Feed

      
        
        52
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        53
        +	is.Equal(t, "test", parsed.Title)

      
        
        54
        +}

      
        
        55
        +

      
        
        56
        +func TestFeedWithAuthor(t *testing.T) {

      
        
        57
        +	feed := NewFeed("test", "feed-id").WithAuthor("moviefeed")

      
        
        58
        +	raw, err := feed.Bytes()

      
        
        59
        +	is.Err(t, err, nil)

      
        
        60
        +	var parsed Feed

      
        
        61
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        62
        +	is.Equal(t, 1, len(parsed.Author))

      
        
        63
        +	is.Equal(t, "moviefeed", parsed.Author[0].Name)

      
        
        64
        +}

      
        
        65
        +

      
        
        66
        +func TestFeedRender(t *testing.T) {

      
        
        67
        +	r := httptest.NewRecorder()

      
        
        68
        +	err := NewFeed("test", "feed-id").

      
        
        69
        +		Add(&Entry{

      
        
        70
        +			Title:   "entry",

      
        
        71
        +			ID:      "entry-id",

      
        
        72
        +			Content: NewText("content", ""),

      
        
        73
        +			Updated: Time(time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)),

      
        
        74
        +		}).

      
        
        75
        +		Render(r)

      
        
        76
        +	is.Err(t, err, nil)

      
        
        77
        +

      
        
        78
        +	is.Equal(t, http.StatusOK, r.Code)

      
        
        79
        +	if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") {

      
        
        80
        +		t.Fatalf("unexpected content type: %q", got)

      
        
        81
        +	}

      
        
        82
        +}

      
        
        83
        +

      
        
        84
        +func TestFeedEntryTextContent(t *testing.T) {

      
        
        85
        +	feed := NewFeed("test", "feed-id").

      
        
        86
        +		Add(&Entry{

      
        
        87
        +			Title:   "text entry",

      
        
        88
        +			ID:      "entry-id",

      
        
        89
        +			Content: NewText("plain text content", "text"),

      
        
        90
        +			Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)),

      
        
        91
        +		})

      
        
        92
        +

      
        
        93
        +	raw, err := feed.Bytes()

      
        
        94
        +	is.Err(t, err, nil)

      
        
        95
        +	if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) {

      
        
        96
        +		t.Fatalf("expected text content with type attribute in serialized feed")

      
        
        97
        +	}

      
        
        98
        +

      
        
        99
        +	var parsed Feed

      
        
        100
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        101
        +	is.Equal(t, 1, len(parsed.Entry))

      
        
        102
        +

      
        
        103
        +	entry := parsed.Entry[0]

      
        
        104
        +	if entry.Content == nil {

      
        
        105
        +		t.Fatalf("expected content element in entry")

      
        
        106
        +	}

      
        
        107
        +	is.Equal(t, "text", entry.Content.Type)

      
        
        108
        +	is.Equal(t, "plain text content", entry.Content.Body)

      
        
        109
        +}

      
        
        110
        +

      
        
        111
        +func TestFeedEntryHtmlContent(t *testing.T) {

      
        
        112
        +	htmlContent := "<p>Hello <strong>World</strong></p>"

      
        
        113
        +	feed := NewFeed("test", "feed-id").

      
        
        114
        +		Add(&Entry{

      
        
        115
        +			Title:   "html entry",

      
        
        116
        +			ID:      "entry-id",

      
        
        117
        +			Content: NewText(htmlContent, "html"),

      
        
        118
        +			Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)),

      
        
        119
        +		})

      
        
        120
        +

      
        
        121
        +	raw, err := feed.Bytes()

      
        
        122
        +	is.Err(t, err, nil)

      
        
        123
        +	if !strings.Contains(string(raw), `<content type="html">`) {

      
        
        124
        +		t.Fatalf("expected HTML content with type='html' attribute in serialized feed")

      
        
        125
        +	}

      
        
        126
        +

      
        
        127
        +	var parsed Feed

      
        
        128
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        129
        +	is.Equal(t, 1, len(parsed.Entry))

      
        
        130
        +

      
        
        131
        +	entry := parsed.Entry[0]

      
        
        132
        +	if entry.Content == nil {

      
        
        133
        +		t.Fatalf("expected content element in entry")

      
        
        134
        +	}

      
        
        135
        +	is.Equal(t, "html", entry.Content.Type)

      
        
        136
        +	is.Equal(t, htmlContent, entry.Content.Body)

      
        
        137
        +}

      
        
        138
        +

      
        
        139
        +func TestFeedEntryXHTMLContent(t *testing.T) {

      
        
        140
        +	xhtmlContent := `<body><p>Hello <strong>World</strong></p></body>`

      
        
        141
        +	feed := NewFeed("test", "feed-id").

      
        
        142
        +		Add(&Entry{

      
        
        143
        +			Title:   "xhtml entry",

      
        
        144
        +			ID:      "entry-id",

      
        
        145
        +			Content: NewText(xhtmlContent, "xhtml"),

      
        
        146
        +			Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)),

      
        
        147
        +		})

      
        
        148
        +

      
        
        149
        +	raw, err := feed.Bytes()

      
        
        150
        +	is.Err(t, err, nil)

      
        
        151
        +	if !strings.Contains(string(raw), `<content type="xhtml">`) {

      
        
        152
        +		t.Fatalf("expected XHTML content with type='xhtml' attribute in serialized feed")

      
        
        153
        +	}

      
        
        154
        +	if !strings.Contains(string(raw), `<div xmlns="http://www.w3.org/1999/xhtml"><body><p>Hello <strong>World</strong></p></body></div>`) {

      
        
        155
        +		t.Fatalf("expected XHTML div wrapper for content")

      
        
        156
        +	}

      
        
        157
        +}

      
        
        158
        +

      
        
        159
        +func TestFeedEntryLinks(t *testing.T) {

      
        
        160
        +	feed := NewFeed("test", "feed-id").

      
        
        161
        +		Add(&Entry{

      
        
        162
        +			Title:   "entry",

      
        
        163
        +			ID:      "entry-id",

      
        
        164
        +			Content: NewText("hello", ""),

      
        
        165
        +			Link: []Link{

      
        
        166
        +				{Rel: "alternate", Type: "text/html", Href: "https://example.com/item"},

      
        
        167
        +			},

      
        
        168
        +			Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)),

      
        
        169
        +		})

      
        
        170
        +

      
        
        171
        +	raw, err := feed.Bytes()

      
        
        172
        +	is.Err(t, err, nil)

      
        
        173
        +

      
        
        174
        +	var parsed Feed

      
        
        175
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        176
        +	is.Equal(t, 1, len(parsed.Entry))

      
        
        177
        +	is.Equal(t, 1, len(parsed.Entry[0].Link))

      
        
        178
        +	is.Equal(t, "https://example.com/item", parsed.Entry[0].Link[0].Href)

      
        
        179
        +}

      
        
        180
        +

      
        
        181
        +func TestFeedEntryLinksWithLength(t *testing.T) {

      
        
        182
        +	feed := NewFeed("test", "feed-id").

      
        
        183
        +		Add(&Entry{

      
        
        184
        +			Title:   "entry",

      
        
        185
        +			ID:      "entry-id",

      
        
        186
        +			Content: NewText("hello", ""),

      
        
        187
        +			Link: []Link{

      
        
        188
        +				{Rel: "enclosure", Type: "image/jpeg", Length: 0, Href: "https://example.com/item.jpg"},

      
        
        189
        +			},

      
        
        190
        +			Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)),

      
        
        191
        +		})

      
        
        192
        +

      
        
        193
        +	raw, err := feed.Bytes()

      
        
        194
        +	is.Err(t, err, nil)

      
        
        195
        +	var parsed Feed

      
        
        196
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        197
        +	if len(parsed.Entry) == 0 || len(parsed.Entry[0].Link) == 0 {

      
        
        198
        +		t.Fatalf("expected enclosure link in parsed feed")

      
        
        199
        +	}

      
        
        200
        +	is.Equal(t, uint(0), parsed.Entry[0].Link[0].Length)

      
        
        201
        +}

      
        
        202
        +

      
        
        203
        +func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) {

      
        
        204
        +	updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)

      
        
        205
        +	feed := NewFeed("test", "feed-id").

      
        
        206
        +		Add(&Entry{

      
        
        207
        +			Title:   "text entry",

      
        
        208
        +			ID:      "entry-text",

      
        
        209
        +			Content: NewText("plain text", "text"),

      
        
        210
        +			Updated: Time(updated),

      
        
        211
        +		}).

      
        
        212
        +		Add(&Entry{

      
        
        213
        +			Title:   "html entry",

      
        
        214
        +			ID:      "entry-html",

      
        
        215
        +			Content: NewText("<p>html content</p>", "html"),

      
        
        216
        +			Updated: Time(updated),

      
        
        217
        +		}).

      
        
        218
        +		Add(&Entry{

      
        
        219
        +			Title:   "default entry",

      
        
        220
        +			ID:      "entry-default",

      
        
        221
        +			Content: NewText("default content", ""),

      
        
        222
        +			Updated: Time(updated),

      
        
        223
        +		})

      
        
        224
        +

      
        
        225
        +	raw, err := feed.Bytes()

      
        
        226
        +	is.Err(t, err, nil)

      
        
        227
        +

      
        
        228
        +	var parsed Feed

      
        
        229
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        230
        +	is.Equal(t, 3, len(parsed.Entry))

      
        
        231
        +

      
        
        232
        +	tests := []struct {

      
        
        233
        +		name         string

      
        
        234
        +		expectedType string

      
        
        235
        +		expectedText string

      
        
        236
        +	}{

      
        
        237
        +		{"text entry", "text", "plain text"},

      
        
        238
        +		{"html entry", "html", "<p>html content</p>"},

      
        
        239
        +		{"default entry", "text", "default content"},

      
        
        240
        +	}

      
        
        241
        +	for i, tc := range tests {

      
        
        242
        +		entry := parsed.Entry[i]

      
        
        243
        +		if entry.Content == nil {

      
        
        244
        +			t.Fatalf("expected content element in entry %d", i)

      
        
        245
        +		}

      
        
        246
        +		is.Equal(t, tc.name, entry.Title)

      
        
        247
        +		is.Equal(t, tc.expectedText, entry.Content.Body)

      
        
        248
        +		is.Equal(t, tc.expectedType, entry.Content.Type)

      
        
        249
        +	}

      
        
        250
        +}

      
A app/atom/feed.go
···
        
        1
        +package atom

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"encoding/xml"

      
        
        6
        +	"io"

      
        
        7
        +	"net/http"

      
        
        8
        +	"strings"

      
        
        9
        +	"time"

      
        
        10
        +)

      
        
        11
        +

      
        
        12
        +func NewFeed(title, id string) *Feed {

      
        
        13
        +	return &Feed{

      
        
        14
        +		Title:   title,

      
        
        15
        +		ID:      id,

      
        
        16
        +		Updated: Time(time.Now()),

      
        
        17
        +		Author:  []*Person{{Name: "rss-tools"}},

      
        
        18
        +	}

      
        
        19
        +}

      
        
        20
        +

      
        
        21
        +func (f *Feed) WithAuthor(name string) *Feed {

      
        
        22
        +	name = strings.TrimSpace(name)

      
        
        23
        +	if name == "" {

      
        
        24
        +		return f

      
        
        25
        +	}

      
        
        26
        +	f.Author = []*Person{{Name: name}}

      
        
        27
        +	return f

      
        
        28
        +}

      
        
        29
        +

      
        
        30
        +func (f *Feed) WithUpdated(updated time.Time) *Feed {

      
        
        31
        +	if !updated.IsZero() {

      
        
        32
        +		f.Updated = Time(updated)

      
        
        33
        +	}

      
        
        34
        +	return f

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +func (f *Feed) Add(entry *Entry) *Feed {

      
        
        38
        +	if entry != nil {

      
        
        39
        +		f.Entry = append(f.Entry, entry)

      
        
        40
        +	}

      
        
        41
        +	return f

      
        
        42
        +}

      
        
        43
        +

      
        
        44
        +func (f *Feed) WriteTo(w io.Writer) error {

      
        
        45
        +	enc := xml.NewEncoder(w)

      
        
        46
        +	enc.Indent("", "  ")

      
        
        47
        +	return enc.Encode(f)

      
        
        48
        +}

      
        
        49
        +

      
        
        50
        +func (f *Feed) Bytes() ([]byte, error) {

      
        
        51
        +	var buf bytes.Buffer

      
        
        52
        +	if err := f.WriteTo(&buf); err != nil {

      
        
        53
        +		return nil, err

      
        
        54
        +	}

      
        
        55
        +	return buf.Bytes(), nil

      
        
        56
        +}

      
        
        57
        +

      
        
        58
        +func (f *Feed) Render(w http.ResponseWriter) error {

      
        
        59
        +	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")

      
        
        60
        +	return f.WriteTo(w)

      
        
        61
        +}

      
        
        62
        +

      
        
        63
        +func NewText(body, typ string) *Text {

      
        
        64
        +	if body == "" && strings.TrimSpace(typ) == "" {

      
        
        65
        +		return nil

      
        
        66
        +	}

      
        
        67
        +	if strings.TrimSpace(typ) == "" {

      
        
        68
        +		typ = "text"

      
        
        69
        +	}

      
        
        70
        +	return &Text{Type: typ, Body: body}

      
        
        71
        +}

      
A app/atom/text.go
···
        
        1
        +package atom

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"encoding/xml"

      
        
        5
        +	"fmt"

      
        
        6
        +	"io"

      
        
        7
        +	"strings"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func (t Text) MarshalXML(e *xml.Encoder, start xml.StartElement) error {

      
        
        11
        +	contentType := strings.TrimSpace(t.Type)

      
        
        12
        +	if contentType == "" {

      
        
        13
        +		contentType = "text"

      
        
        14
        +	}

      
        
        15
        +

      
        
        16
        +	start.Attr = append(start.Attr, xml.Attr{

      
        
        17
        +		Name:  xml.Name{Local: "type"},

      
        
        18
        +		Value: contentType,

      
        
        19
        +	})

      
        
        20
        +

      
        
        21
        +	if err := e.EncodeToken(start); err != nil {

      
        
        22
        +		return err

      
        
        23
        +	}

      
        
        24
        +

      
        
        25
        +	if contentType == "xhtml" {

      
        
        26
        +		if err := validateXHTMLFragment(t.Body); err != nil {

      
        
        27
        +			return err

      
        
        28
        +		}

      
        
        29
        +		if err := e.Encode(xhtmlDiv{

      
        
        30
        +			XMLNS: xhtmlNamespace,

      
        
        31
        +			Inner: t.Body,

      
        
        32
        +		}); err != nil {

      
        
        33
        +			return err

      
        
        34
        +		}

      
        
        35
        +	} else {

      
        
        36
        +		if err := e.EncodeToken(xml.CharData([]byte(t.Body))); err != nil {

      
        
        37
        +			return err

      
        
        38
        +		}

      
        
        39
        +	}

      
        
        40
        +

      
        
        41
        +	if err := e.EncodeToken(start.End()); err != nil {

      
        
        42
        +		return err

      
        
        43
        +	}

      
        
        44
        +	return e.Flush()

      
        
        45
        +}

      
        
        46
        +

      
        
        47
        +type xhtmlDiv struct {

      
        
        48
        +	XMLName xml.Name `xml:"div"`

      
        
        49
        +	XMLNS   string   `xml:"xmlns,attr"`

      
        
        50
        +	Inner   string   `xml:",innerxml"`

      
        
        51
        +}

      
        
        52
        +

      
        
        53
        +func validateXHTMLFragment(fragment string) error {

      
        
        54
        +	wrapped := fmt.Sprintf(`<div xmlns="%s">%s</div>`, xhtmlNamespace, fragment)

      
        
        55
        +	dec := xml.NewDecoder(strings.NewReader(wrapped))

      
        
        56
        +	for {

      
        
        57
        +		_, err := dec.Token()

      
        
        58
        +		if err == io.EOF {

      
        
        59
        +			return nil

      
        
        60
        +		}

      
        
        61
        +		if err != nil {

      
        
        62
        +			return fmt.Errorf("invalid xhtml content: %w", err)

      
        
        63
        +		}

      
        
        64
        +	}

      
        
        65
        +}

      
D app/atom_test.go
···
        1
        
        -package app

      
        2
        
        -

      
        3
        
        -import (

      
        4
        
        -	"bytes"

      
        5
        
        -	"encoding/xml"

      
        6
        
        -	"net/http"

      
        7
        
        -	"net/http/httptest"

      
        8
        
        -	"strings"

      
        9
        
        -	"testing"

      
        10
        
        -	"time"

      
        11
        
        -

      
        12
        
        -	"olexsmir.xyz/x/is"

      
        13
        
        -)

      
        14
        
        -

      
        15
        
        -func TestFeedBuilderAddEntryDefaults(t *testing.T) {

      
        16
        
        -	feed := NewFeed("test", "feed-id")

      
        17
        
        -	feed.Add(FeedEntry{Title: "entry", Content: "body"})

      
        18
        
        -

      
        19
        
        -	is.Equal(t, 1, len(feed.f.Entries))

      
        20
        
        -	entry := feed.f.Entries[0]

      
        21
        
        -	is.NotEqual(t, "", entry.ID)

      
        22
        
        -	is.NotEqual(t, "", entry.Updated)

      
        23
        
        -	is.Equal(t, 1, len(feed.f.Authors))

      
        24
        
        -	is.Equal(t, "rss-tools", feed.f.Authors[0].Name)

      
        25
        
        -}

      
        26
        
        -

      
        27
        
        -func TestFeedBuilderBytesAndWriteTo(t *testing.T) {

      
        28
        
        -	updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC)

      
        29
        
        -	feed := NewFeed("test", "feed-id").

      
        30
        
        -		WithSubtitle("subtitle").

      
        31
        
        -		Add(FeedEntry{Title: "entry", Content: "content", Updated: updated})

      
        32
        
        -

      
        33
        
        -	raw, err := feed.Bytes()

      
        34
        
        -	is.Err(t, err, nil)

      
        35
        
        -	if !bytes.Contains(raw, []byte("<subtitle>subtitle</subtitle>")) {

      
        36
        
        -		t.Fatalf("expected subtitle in serialized feed")

      
        37
        
        -	}

      
        38
        
        -

      
        39
        
        -	var parsed AtomFeed

      
        40
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        41
        
        -	is.Equal(t, "test", parsed.Title)

      
        42
        
        -}

      
        43
        
        -

      
        44
        
        -func TestFeedBuilderWithAuthor(t *testing.T) {

      
        45
        
        -	feed := NewFeed("test", "feed-id").WithAuthor("moviefeed")

      
        46
        
        -	raw, err := feed.Bytes()

      
        47
        
        -	is.Err(t, err, nil)

      
        48
        
        -	var parsed AtomFeed

      
        49
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        50
        
        -	is.Equal(t, 1, len(parsed.Authors))

      
        51
        
        -	is.Equal(t, "moviefeed", parsed.Authors[0].Name)

      
        52
        
        -}

      
        53
        
        -

      
        54
        
        -func TestFeedBuilderRender(t *testing.T) {

      
        55
        
        -	r := httptest.NewRecorder()

      
        56
        
        -	err := NewFeed("test", "feed-id").

      
        57
        
        -		Add(FeedEntry{

      
        58
        
        -			Title:   "entry",

      
        59
        
        -			ID:      "entry-id",

      
        60
        
        -			Content: "content",

      
        61
        
        -			Updated: time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC),

      
        62
        
        -		}).

      
        63
        
        -		Render(r)

      
        64
        
        -	is.Err(t, err, nil)

      
        65
        
        -

      
        66
        
        -	is.Equal(t, http.StatusOK, r.Code)

      
        67
        
        -	if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") {

      
        68
        
        -		t.Fatalf("unexpected content type: %q", got)

      
        69
        
        -	}

      
        70
        
        -}

      
        71
        
        -

      
        72
        
        -func TestFeedEntryTextContent(t *testing.T) {

      
        73
        
        -	feed := NewFeed("test", "feed-id").

      
        74
        
        -		Add(FeedEntry{

      
        75
        
        -			Title:       "text entry",

      
        76
        
        -			Content:     "plain text content",

      
        77
        
        -			ContentType: "text",

      
        78
        
        -			Updated:     time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        79
        
        -		})

      
        80
        
        -

      
        81
        
        -	raw, err := feed.Bytes()

      
        82
        
        -	is.Err(t, err, nil)

      
        83
        
        -	if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) {

      
        84
        
        -		t.Fatalf("expected text content with type attribute in serialized feed")

      
        85
        
        -	}

      
        86
        
        -

      
        87
        
        -	var parsed AtomFeed

      
        88
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        89
        
        -	is.Equal(t, 1, len(parsed.Entries))

      
        90
        
        -

      
        91
        
        -	entry := parsed.Entries[0]

      
        92
        
        -	is.Equal(t, "text", entry.Content.Type)

      
        93
        
        -	is.Equal(t, "plain text content", entry.Content.Value)

      
        94
        
        -}

      
        95
        
        -

      
        96
        
        -func TestFeedEntryHtmlContent(t *testing.T) {

      
        97
        
        -	htmlContent := "<p>Hello <strong>World</strong></p>"

      
        98
        
        -	feed := NewFeed("test", "feed-id").

      
        99
        
        -		Add(FeedEntry{

      
        100
        
        -			Title:       "html entry",

      
        101
        
        -			Content:     htmlContent,

      
        102
        
        -			ContentType: "html",

      
        103
        
        -			Updated:     time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        104
        
        -		})

      
        105
        
        -

      
        106
        
        -	raw, err := feed.Bytes()

      
        107
        
        -	is.Err(t, err, nil)

      
        108
        
        -	if !strings.Contains(string(raw), `<content type="html">`) {

      
        109
        
        -		t.Fatalf("expected HTML content with type='html' attribute in serialized feed")

      
        110
        
        -	}

      
        111
        
        -

      
        112
        
        -	var parsed AtomFeed

      
        113
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        114
        
        -	is.Equal(t, 1, len(parsed.Entries))

      
        115
        
        -

      
        116
        
        -	entry := parsed.Entries[0]

      
        117
        
        -	is.Equal(t, "html", entry.Content.Type)

      
        118
        
        -	is.Equal(t, htmlContent, entry.Content.Value)

      
        119
        
        -}

      
        120
        
        -

      
        121
        
        -func TestFeedEntryXHTMLContent(t *testing.T) {

      
        122
        
        -	xhtmlContent := `<body><p>Hello <strong>World</strong></p></body>`

      
        123
        
        -	feed := NewFeed("test", "feed-id").

      
        124
        
        -		Add(FeedEntry{

      
        125
        
        -			Title:       "xhtml entry",

      
        126
        
        -			Content:     xhtmlContent,

      
        127
        
        -			ContentType: "xhtml",

      
        128
        
        -			Updated:     time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        129
        
        -		})

      
        130
        
        -

      
        131
        
        -	raw, err := feed.Bytes()

      
        132
        
        -	is.Err(t, err, nil)

      
        133
        
        -	if !strings.Contains(string(raw), `<content type="xhtml">`) {

      
        134
        
        -		t.Fatalf("expected XHTML content with type='xhtml' attribute in serialized feed")

      
        135
        
        -	}

      
        136
        
        -	if !strings.Contains(string(raw), `<div xmlns="http://www.w3.org/1999/xhtml"><body><p>Hello <strong>World</strong></p></body></div>`) {

      
        137
        
        -		t.Fatalf("expected XHTML div wrapper for content")

      
        138
        
        -	}

      
        139
        
        -}

      
        140
        
        -

      
        141
        
        -func TestFeedEntryLinks(t *testing.T) {

      
        142
        
        -	feed := NewFeed("test", "feed-id").

      
        143
        
        -		Add(FeedEntry{

      
        144
        
        -			Title:   "entry",

      
        145
        
        -			Content: "hello",

      
        146
        
        -			Links: []FeedLink{

      
        147
        
        -				{Rel: "alternate", Type: "text/html", Href: "https://example.com/item"},

      
        148
        
        -			},

      
        149
        
        -			Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        150
        
        -		})

      
        151
        
        -

      
        152
        
        -	raw, err := feed.Bytes()

      
        153
        
        -	is.Err(t, err, nil)

      
        154
        
        -	if !strings.Contains(string(raw), `<link rel="alternate" type="text/html" href="https://example.com/item"></link>`) {

      
        155
        
        -		t.Fatalf("expected link element in serialized feed")

      
        156
        
        -	}

      
        157
        
        -

      
        158
        
        -	var parsed AtomFeed

      
        159
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        160
        
        -	is.Equal(t, 1, len(parsed.Entries))

      
        161
        
        -	is.Equal(t, 1, len(parsed.Entries[0].Links))

      
        162
        
        -	is.Equal(t, "https://example.com/item", parsed.Entries[0].Links[0].Href)

      
        163
        
        -}

      
        164
        
        -

      
        165
        
        -func TestFeedEntryLinksWithLength(t *testing.T) {

      
        166
        
        -	feed := NewFeed("test", "feed-id").

      
        167
        
        -		Add(FeedEntry{

      
        168
        
        -			Title:   "entry",

      
        169
        
        -			Content: "hello",

      
        170
        
        -			Links: []FeedLink{

      
        171
        
        -				{Rel: "enclosure", Type: "image/jpeg", Length: "0", Href: "https://example.com/item.jpg"},

      
        172
        
        -			},

      
        173
        
        -			Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        174
        
        -		})

      
        175
        
        -

      
        176
        
        -	raw, err := feed.Bytes()

      
        177
        
        -	is.Err(t, err, nil)

      
        178
        
        -	if !strings.Contains(string(raw), `rel="enclosure" type="image/jpeg" length="0" href="https://example.com/item.jpg"`) {

      
        179
        
        -		t.Fatalf("expected enclosure link with length in serialized feed")

      
        180
        
        -	}

      
        181
        
        -}

      
        182
        
        -

      
        183
        
        -func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) {

      
        184
        
        -	updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)

      
        185
        
        -	feed := NewFeed("test", "feed-id").

      
        186
        
        -		Add(FeedEntry{

      
        187
        
        -			Title:       "text entry",

      
        188
        
        -			Content:     "plain text",

      
        189
        
        -			ContentType: "text",

      
        190
        
        -			Updated:     updated,

      
        191
        
        -		}).

      
        192
        
        -		Add(FeedEntry{

      
        193
        
        -			Title:       "html entry",

      
        194
        
        -			Content:     "<p>html content</p>",

      
        195
        
        -			ContentType: "html",

      
        196
        
        -			Updated:     updated,

      
        197
        
        -		}).

      
        198
        
        -		Add(FeedEntry{

      
        199
        
        -			Title:   "default entry",

      
        200
        
        -			Content: "default content",

      
        201
        
        -			Updated: updated,

      
        202
        
        -		})

      
        203
        
        -

      
        204
        
        -	raw, err := feed.Bytes()

      
        205
        
        -	is.Err(t, err, nil)

      
        206
        
        -

      
        207
        
        -	var parsed AtomFeed

      
        208
        
        -	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        209
        
        -	is.Equal(t, 3, len(parsed.Entries))

      
        210
        
        -

      
        211
        
        -	tests := []struct {

      
        212
        
        -		name         string

      
        213
        
        -		expectedType string

      
        214
        
        -		expectedText string

      
        215
        
        -	}{

      
        216
        
        -		{"text entry", "text", "plain text"},

      
        217
        
        -		{"html entry", "html", "<p>html content</p>"},

      
        218
        
        -		{"default entry", "text", "default content"},

      
        219
        
        -	}

      
        220
        
        -	for i, tc := range tests {

      
        221
        
        -		is.Equal(t, tc.name, parsed.Entries[i].Title)

      
        222
        
        -		is.Equal(t, tc.expectedText, parsed.Entries[i].Content.Value)

      
        223
        
        -		is.Equal(t, tc.expectedType, parsed.Entries[i].Content.Type)

      
        224
        
        -	}

      
        225
        
        -}

      
M sources/moviefeed/moviefeed.go
···
        9
        9
         	"time"

      
        10
        10
         

      
        11
        11
         	"olexsmir.xyz/rss-tools/app"

      
        
        12
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        12
        13
         )

      
        13
        14
         

      
        14
        15
         type moviefeed struct {

      ···
        60
        61
         	return allEpisodes, nil

      
        61
        62
         }

      
        62
        63
         

      
        63
        
        -func generateFeed(episodes []TMDBEpisode) *app.FeedBuilder {

      
        64
        
        -	feed := app.NewFeed("moviefeed", "moviefeed").

      
        65
        
        -		WithSubtitle("Latest episodes from followed shows")

      
        
        64
        +func generateFeed(episodes []TMDBEpisode) *atom.Feed {

      
        
        65
        +	feed := atom.NewFeed("moviefeed", "moviefeed")

      
        66
        66
         

      
        67
        67
         	for i := len(episodes) - 1; i >= 0; i-- {

      
        68
        68
         		ep := episodes[i]

      
        69
        69
         		airDate, _ := time.Parse(dateFormat, ep.AirDate)

      
        70
        70
         		content, contentType := episodeContent(ep)

      
        71
        
        -		links := []app.FeedLink{

      
        
        71
        +		links := []atom.Link{

      
        72
        72
         			{

      
        73
        73
         				Rel:  "alternate",

      
        74
        74
         				Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID),

      
        75
        75
         			},

      
        76
        76
         		}

      
        77
        77
         		if ep.StillPath != "" {

      
        78
        
        -			links = append(links, app.FeedLink{

      
        
        78
        +			links = append(links, atom.Link{

      
        79
        79
         				Rel:    "enclosure",

      
        80
        80
         				Type:   "image/jpeg",

      
        81
        
        -				Length: "0",

      
        
        81
        +				Length: 0,

      
        82
        82
         				Href:   tmdbImageBaseURL + ep.StillPath,

      
        83
        83
         			})

      
        84
        84
         		}

      
        85
        85
         

      
        86
        
        -		feed.Add(app.FeedEntry{

      
        
        86
        +		feed.Add(&atom.Entry{

      
        87
        87
         			ID: fmt.Sprintf("%s-%d-%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber),

      
        88
        88
         			Title: fmt.Sprintf(

      
        89
        89
         				"%s S%dE%d: %s",

      ···
        92
        92
         				ep.EpisodeNumber,

      
        93
        93
         				ep.Name,

      
        94
        94
         			),

      
        95
        
        -			Content:     content,

      
        96
        
        -			ContentType: contentType,

      
        97
        
        -			Updated:     airDate,

      
        98
        
        -			Links:       links,

      
        
        95
        +			Content: atom.NewText(content, contentType),

      
        
        96
        +			Updated: atom.Time(airDate),

      
        
        97
        +			Link:    links,

      
        99
        98
         		})

      
        100
        99
         	}

      
        101
        100
         	return feed

      
M sources/moviefeed/moviefeed_test.go
···
        10
        10
         	"testing"

      
        11
        11
         	"time"

      
        12
        12
         

      
        13
        
        -	"olexsmir.xyz/rss-tools/app"

      
        
        13
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        14
        14
         	"olexsmir.xyz/x/is"

      
        15
        15
         )

      
        16
        16
         

      ···
        35
        35
         		t.Fatalf("expected atom response content-type, got %q", got)

      
        36
        36
         	}

      
        37
        37
         

      
        38
        
        -	var feed app.AtomFeed

      
        
        38
        +	var feed atom.Feed

      
        39
        39
         	is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil)

      
        40
        40
         	is.Equal(t, feed.Title, "moviefeed")

      
        41
        41
         	is.Equal(t, feed.Subtitle, "Latest episodes from followed shows")

      
        42
        
        -	is.Equal(t, len(feed.Entries), 2)

      
        43
        
        -	is.Equal(t, strings.Contains(feed.Entries[0].Title, "S1E2"), true)

      
        44
        
        -	is.Equal(t, feed.Entries[0].Content.Type, "text")

      
        45
        
        -	is.Equal(t, len(feed.Entries[1].Links), 2)

      
        46
        
        -	is.Equal(t, feed.Entries[1].Links[1].Rel, "enclosure")

      
        47
        
        -	is.Equal(t, feed.Entries[1].Links[1].Type, "image/jpeg")

      
        48
        
        -	is.Equal(t, feed.Entries[1].Links[1].Length, "0")

      
        49
        
        -	is.Equal(t, feed.Entries[1].Links[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg")

      
        50
        
        -	is.Equal(t, feed.Entries[1].Content.Type, "xhtml")

      
        
        42
        +	is.Equal(t, len(feed.Entry), 2)

      
        
        43
        +	is.Equal(t, strings.Contains(feed.Entry[0].Title, "S1E2"), true)

      
        
        44
        +	is.Equal(t, feed.Entry[0].Content.Type, "text")

      
        
        45
        +	is.Equal(t, len(feed.Entry[1].Link), 2)

      
        
        46
        +	is.Equal(t, feed.Entry[1].Link[1].Rel, "enclosure")

      
        
        47
        +	is.Equal(t, feed.Entry[1].Link[1].Type, "image/jpeg")

      
        
        48
        +	is.Equal(t, feed.Entry[1].Link[1].Length, uint(0))

      
        
        49
        +	is.Equal(t, feed.Entry[1].Link[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg")

      
        
        50
        +	is.Equal(t, feed.Entry[1].Content.Type, "xhtml")

      
        51
        51
         }

      
        52
        52
         

      
        53
        53
         func TestHandleMoviesContinuesWhenOneShowFails(t *testing.T) {

      ···
        68
        68
         

      
        69
        69
         	is.Equal(t, rr.Code, http.StatusOK)

      
        70
        70
         

      
        71
        
        -	var feed app.AtomFeed

      
        
        71
        +	var feed atom.Feed

      
        72
        72
         	is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil)

      
        73
        
        -	is.Equal(t, len(feed.Entries), 2)

      
        
        73
        +	is.Equal(t, len(feed.Entry), 2)

      
        74
        74
         }

      
        75
        75
         

      
        76
        76
         func TestFetchEpisodesForShowFiltersRecentAndMapsFields(t *testing.T) {

      
M sources/telegram/links.go
···
        7
        7
         	"regexp"

      
        8
        8
         	"strings"

      
        9
        9
         

      
        10
        
        -	"olexsmir.xyz/rss-tools/app"

      
        
        10
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        11
        11
         )

      
        12
        12
         

      
        13
        13
         var (

      ···
        90
        90
         	return out

      
        91
        91
         }

      
        92
        92
         

      
        93
        
        -func feedLinks(urls []string) []app.FeedLink {

      
        94
        
        -	links := make([]app.FeedLink, 0, len(urls))

      
        
        93
        +func feedLinks(urls []string) []atom.Link {

      
        
        94
        +	links := make([]atom.Link, 0, len(urls))

      
        95
        95
         	for _, u := range urls {

      
        96
        
        -		links = append(links, app.FeedLink{

      
        
        96
        +		links = append(links, atom.Link{

      
        97
        97
         			Rel:  "alternate",

      
        98
        98
         			Type: "text/html",

      
        99
        99
         			Href: u,

      
M sources/telegram/telegram.go
···
        13
        13
         	"time"

      
        14
        14
         

      
        15
        15
         	"olexsmir.xyz/rss-tools/app"

      
        
        16
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        16
        17
         )

      
        17
        18
         

      
        18
        19
         type telegram struct {

      ···
        62
        63
         		return

      
        63
        64
         	}

      
        64
        65
         

      
        65
        
        -	feed := app.NewFeed("Telegram feed", "telegram-feed")

      
        
        66
        +	feed := atom.NewFeed("Telegram feed", "telegram-feed")

      
        66
        67
         	for _, m := range messages {

      
        67
        68
         		if changed := t.enrichMessageWithLinkTitles(r.Context(), m); changed {

      
        68
        69
         			if err := t.saveMessage(m); err != nil {

      ···
        261
        262
         	return changed

      
        262
        263
         }

      
        263
        264
         

      
        264
        
        -func feedEntryFromMessage(m *Message) app.FeedEntry {

      
        
        265
        +func feedEntryFromMessage(m *Message) *atom.Entry {

      
        265
        266
         	updated := time.Unix(m.Date, 0)

      
        266
        267
         	text := normalizeMessageText(messageText(m))

      
        267
        268
         	normalizedLinks := normalizeLinks(messageLinks(text))

      ···
        296
        297
         			contentType = "html"

      
        297
        298
         		}

      
        298
        299
         

      
        299
        
        -		return app.FeedEntry{

      
        300
        
        -			Title:       title,

      
        301
        
        -			ID:          entryID,

      
        302
        
        -			Links:       feedLinks(normalizedLinks),

      
        303
        
        -			Content:     content,

      
        304
        
        -			Updated:     updated,

      
        305
        
        -			ContentType: contentType,

      
        
        300
        +		return &atom.Entry{

      
        
        301
        +			Title:   title,

      
        
        302
        +			ID:      entryID,

      
        
        303
        +			Link:    feedLinks(normalizedLinks),

      
        
        304
        +			Content: atom.NewText(content, contentType),

      
        
        305
        +			Updated: atom.Time(updated),

      
        306
        306
         		}

      
        307
        307
         	}

      
        308
        308
         

      ···
        323
        323
         		parts = append(parts, fmt.Sprintf(`<p><img src="data:%s;base64,%s" alt="telegram image"/></p>`, mimeType, photo.Base64))

      
        324
        324
         	}

      
        325
        325
         

      
        326
        
        -	return app.FeedEntry{

      
        327
        
        -		Title:       fmt.Sprintf("🖼️ [%s]", updated.Format("2006-01-02")),

      
        328
        
        -		ID:          entryID,

      
        329
        
        -		Links:       feedLinks(normalizedLinks),

      
        330
        
        -		Content:     strings.Join(parts, ""),

      
        331
        
        -		ContentType: "html",

      
        332
        
        -		Updated:     updated,

      
        
        326
        +	return &atom.Entry{

      
        
        327
        +		Title:   fmt.Sprintf("🖼️ [%s]", updated.Format("2006-01-02")),

      
        
        328
        +		ID:      entryID,

      
        
        329
        +		Link:    feedLinks(normalizedLinks),

      
        
        330
        +		Content: atom.NewText(strings.Join(parts, ""), "html"),

      
        
        331
        +		Updated: atom.Time(updated),

      
        333
        332
         	}

      
        334
        333
         }

      
        335
        334
         

      
M sources/telegram/telegram_test.go
···
        19
        19
         

      
        20
        20
         	entry := feedEntryFromMessage(msg)

      
        21
        21
         	is.Equal(t, "🖼️ [2026-04-22]", entry.Title)

      
        22
        
        -	is.Equal(t, "html", entry.ContentType)

      
        23
        
        -	if !strings.Contains(entry.Content, "<p>hello &lt;world&gt;</p>") {

      
        24
        
        -		t.Fatalf("expected escaped text in image entry: %s", entry.Content)

      
        
        22
        +	if entry.Content == nil {

      
        
        23
        +		t.Fatalf("expected content in image entry")

      
        
        24
        +	}

      
        
        25
        +	is.Equal(t, "html", entry.Content.Type)

      
        
        26
        +	if !strings.Contains(entry.Content.Body, "<p>hello &lt;world&gt;</p>") {

      
        
        27
        +		t.Fatalf("expected escaped text in image entry: %s", entry.Content.Body)

      
        25
        28
         	}

      
        26
        
        -	if !strings.Contains(entry.Content, `src="data:image/png;base64,YWJj"`) {

      
        27
        
        -		t.Fatalf("expected image data URI in image entry: %s", entry.Content)

      
        
        29
        +	if !strings.Contains(entry.Content.Body, `src="data:image/png;base64,YWJj"`) {

      
        
        30
        +		t.Fatalf("expected image data URI in image entry: %s", entry.Content.Body)

      
        28
        31
         	}

      
        29
        32
         }

      
        30
        33
         

      ···
        40
        43
         	}

      
        41
        44
         

      
        42
        45
         	entry := feedEntryFromMessage(msg)

      
        43
        
        -	if !strings.Contains(entry.Content, `src="data:image/png;base64,YWJj"`) {

      
        44
        
        -		t.Fatalf("expected first image data URI in image entry: %s", entry.Content)

      
        
        46
        +	if entry.Content == nil {

      
        
        47
        +		t.Fatalf("expected content in image entry")

      
        45
        48
         	}

      
        46
        
        -	if !strings.Contains(entry.Content, `src="data:image/jpeg;base64,ZGVm"`) {

      
        47
        
        -		t.Fatalf("expected second image data URI in image entry: %s", entry.Content)

      
        
        49
        +	if !strings.Contains(entry.Content.Body, `src="data:image/png;base64,YWJj"`) {

      
        
        50
        +		t.Fatalf("expected first image data URI in image entry: %s", entry.Content.Body)

      
        
        51
        +	}

      
        
        52
        +	if !strings.Contains(entry.Content.Body, `src="data:image/jpeg;base64,ZGVm"`) {

      
        
        53
        +		t.Fatalf("expected second image data URI in image entry: %s", entry.Content.Body)

      
        48
        54
         	}

      
        49
        55
         }

      
        50
        56
         

      ···
        57
        63
         

      
        58
        64
         	entry := feedEntryFromMessage(msg)

      
        59
        65
         	is.Equal(t, "plain text", entry.Title)

      
        60
        
        -	is.Equal(t, "", entry.ContentType)

      
        61
        
        -	is.Equal(t, "plain text", entry.Content)

      
        
        66
        +	if entry.Content == nil {

      
        
        67
        +		t.Fatalf("expected content in text entry")

      
        
        68
        +	}

      
        
        69
        +	is.Equal(t, "text", entry.Content.Type)

      
        
        70
        +	is.Equal(t, "plain text", entry.Content.Body)

      
        62
        71
         }

      
        63
        72
         

      
        64
        73
         func TestFeedEntryFromMessagePreservesNewlines(t *testing.T) {

      ···
        69
        78
         	}

      
        70
        79
         

      
        71
        80
         	entry := feedEntryFromMessage(msg)

      
        72
        
        -	is.Equal(t, "html", entry.ContentType)

      
        73
        
        -	if !strings.Contains(entry.Content, "line 1<br/>line 2") {

      
        74
        
        -		t.Fatalf("expected line breaks preserved in content: %s", entry.Content)

      
        
        81
        +	if entry.Content == nil {

      
        
        82
        +		t.Fatalf("expected content in text entry")

      
        
        83
        +	}

      
        
        84
        +	is.Equal(t, "html", entry.Content.Type)

      
        
        85
        +	if !strings.Contains(entry.Content.Body, "line 1<br/>line 2") {

      
        
        86
        +		t.Fatalf("expected line breaks preserved in content: %s", entry.Content.Body)

      
        75
        87
         	}

      
        76
        88
         }

      
        77
        89
         

      ···
        83
        95
         	}

      
        84
        96
         

      
        85
        97
         	entry := feedEntryFromMessage(msg)

      
        86
        
        -	is.Equal(t, "html", entry.ContentType)

      
        87
        
        -	if !strings.Contains(entry.Content, `<a href="https://example.com">https://example.com</a>`) {

      
        88
        
        -		t.Fatalf("expected generic link in content: %s", entry.Content)

      
        
        98
        +	if entry.Content == nil {

      
        
        99
        +		t.Fatalf("expected content in link entry")

      
        
        100
        +	}

      
        
        101
        +	is.Equal(t, "html", entry.Content.Type)

      
        
        102
        +	if !strings.Contains(entry.Content.Body, `<a href="https://example.com">https://example.com</a>`) {

      
        
        103
        +		t.Fatalf("expected generic link in content: %s", entry.Content.Body)

      
        89
        104
         	}

      
        90
        
        -	if !strings.Contains(entry.Content, `<a href="https://youtu.be/dQw4w9WgXcQ">https://youtu.be/dQw4w9WgXcQ</a>`) {

      
        91
        
        -		t.Fatalf("expected youtube link in content: %s", entry.Content)

      
        
        105
        +	if !strings.Contains(entry.Content.Body, `<a href="https://youtu.be/dQw4w9WgXcQ">https://youtu.be/dQw4w9WgXcQ</a>`) {

      
        
        106
        +		t.Fatalf("expected youtube link in content: %s", entry.Content.Body)

      
        92
        107
         	}

      
        93
        108
         

      
        94
        
        -	is.Equal(t, 2, len(entry.Links))

      
        95
        
        -	is.Equal(t, "https://example.com", entry.Links[0].Href)

      
        96
        
        -	is.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", entry.Links[1].Href)

      
        
        109
        +	is.Equal(t, 2, len(entry.Link))

      
        
        110
        +	is.Equal(t, "https://example.com", entry.Link[0].Href)

      
        
        111
        +	is.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", entry.Link[1].Href)

      
        97
        112
         	is.Equal(t, "yt:video:dQw4w9WgXcQ", entry.ID)

      
        98
        113
         }

      
        99
        114
         

      
M sources/weather/weather.go
···
        14
        14
         	"time"

      
        15
        15
         

      
        16
        16
         	"olexsmir.xyz/rss-tools/app"

      
        
        17
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        17
        18
         )

      
        18
        19
         

      
        19
        20
         const (

      ···
        77
        78
         	}

      
        78
        79
         

      
        79
        80
         	feedID := weatherFeedID(lat, lon)

      
        80
        
        -	feed := app.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID).

      
        
        81
        +	feed := atom.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID).

      
        81
        82
         		WithUpdated(updated)

      
        82
        83
         

      
        83
        
        -	feed.Add(app.FeedEntry{

      
        84
        
        -		Title:       "Weather briefing",

      
        85
        
        -		ID:          fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")),

      
        86
        
        -		Content:     formatBriefingXHTML(content),

      
        87
        
        -		ContentType: "xhtml",

      
        88
        
        -		Updated:     updated,

      
        
        84
        +	feed.Add(&atom.Entry{

      
        
        85
        +		Title:   "Weather briefing",

      
        
        86
        +		ID:      fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")),

      
        
        87
        +		Content: atom.NewText(formatBriefingXHTML(content), "xhtml"),

      
        
        88
        +		Updated: atom.Time(updated),

      
        89
        89
         	})

      
        90
        90
         

      
        91
        91
         	if err := feed.Render(rw); err != nil {

      
M sources/weather/weather_test.go
···
        8
        8
         	"testing"

      
        9
        9
         	"time"

      
        10
        10
         

      
        11
        
        -	"olexsmir.xyz/rss-tools/app"

      
        
        11
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        12
        12
         	"olexsmir.xyz/x/is"

      
        13
        13
         )

      
        14
        14
         

      ···
        106
        106
         	}

      
        107
        107
         

      
        108
        108
         	raw := rr.Body.String()

      
        109
        
        -	var feed app.AtomFeed

      
        
        109
        +	var feed atom.Feed

      
        110
        110
         	is.Err(t, xml.NewDecoder(strings.NewReader(raw)).Decode(&feed), nil)

      
        111
        
        -	is.Equal(t, len(feed.Entries), 1)

      
        
        111
        +	is.Equal(t, len(feed.Entry), 1)

      
        112
        112
         	is.Equal(t, feed.Title, "Weather forecast for Kyiv")

      
        113
        
        -	is.Equal(t, feed.Entries[0].Title, "Weather briefing")

      
        114
        
        -	is.Equal(t, feed.Entries[0].Content.Type, "xhtml")

      
        
        113
        +	is.Equal(t, feed.Entry[0].Title, "Weather briefing")

      
        
        114
        +	if feed.Entry[0].Content == nil {

      
        
        115
        +		t.Fatalf("expected xhtml content")

      
        
        116
        +	}

      
        
        117
        +	is.Equal(t, feed.Entry[0].Content.Type, "xhtml")

      
        115
        118
         

      
        116
        119
         	content := raw

      
        117
        120
         	if !strings.Contains(content, `<content type="xhtml">`) {

      
M sources/ztoe/ztoe.go
···
        15
        15
         	"golang.org/x/net/html/charset"

      
        16
        16
         

      
        17
        17
         	"olexsmir.xyz/rss-tools/app"

      
        
        18
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        18
        19
         )

      
        19
        20
         

      
        20
        21
         type ztoe struct {

      ···
        52
        53
         			slots = append(slots, slot{Range: t, Outage: i < len(row) && row[i]})

      
        53
        54
         		}

      
        54
        55
         

      
        55
        
        -		feed := app.NewFeed(

      
        
        56
        +		feed := atom.NewFeed(

      
        56
        57
         			fmt.Sprintf("ZTOE power outages for %s.%s", group, subgroup),

      
        57
        58
         			fmt.Sprintf("ztoe-%s-%s", group, subgroup))

      
        58
        59
         

      
        59
        60
         		for _, interval := range buildOutageIntervals(slots) {

      
        60
        
        -			feed.Add(app.FeedEntry{

      
        
        61
        +			feed.Add(&atom.Entry{

      
        61
        62
         				Title:   fmt.Sprintf("Power outage %s-%s", interval.Start, interval.End),

      
        62
        63
         				ID:      fmt.Sprintf("ztoe-%s-%s-%s-%s-%s", group, subgroup, schedule.Date, strings.ReplaceAll(interval.Start, ":", ""), strings.ReplaceAll(interval.End, ":", "")),

      
        63
        
        -				Content: fmt.Sprintf("Date: %s\nGroup: %s.%s\nTime: %s-%s", schedule.Date, group, subgroup, interval.Start, interval.End),

      
        64
        
        -				Updated: intervalTime(schedule.Date, interval.Start),

      
        
        64
        +				Content: atom.NewText(fmt.Sprintf("Date: %s\nGroup: %s.%s\nTime: %s-%s", schedule.Date, group, subgroup, interval.Start, interval.End), ""),

      
        
        65
        +				Updated: atom.Time(intervalTime(schedule.Date, interval.Start)),

      
        65
        66
         			})

      
        66
        67
         		}

      
        67
        68
         		if err := feed.Render(w); err != nil {

      
M sources/ztoe/ztoe_test.go
···
        11
        11
         	"strings"

      
        12
        12
         	"testing"

      
        13
        13
         

      
        14
        
        -	"olexsmir.xyz/rss-tools/app"

      
        
        14
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        15
        15
         	"olexsmir.xyz/x/is"

      
        16
        16
         )

      
        17
        17
         

      ···
        70
        70
         		t.Fatalf("expected atom response content-type, got %q", got)

      
        71
        71
         	}

      
        72
        72
         

      
        73
        
        -	var feed app.AtomFeed

      
        
        73
        +	var feed atom.Feed

      
        74
        74
         	is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil)

      
        75
        
        -	is.NotEqual(t, 0, len(feed.Entries))

      
        
        75
        +	is.NotEqual(t, 0, len(feed.Entry))

      
        76
        76
         }

      
        77
        77
         

      
        78
        78
         func TestHandlerRendersEmptyAtomFeedForNoOutages(t *testing.T) {

      ···
        100
        100
         		t.Fatalf("expected status 200, got %d", rr.Code)

      
        101
        101
         	}

      
        102
        102
         

      
        103
        
        -	var feed app.AtomFeed

      
        
        103
        +	var feed atom.Feed

      
        104
        104
         	is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil)

      
        105
        
        -	is.Equal(t, 0, len(feed.Entries))

      
        
        105
        +	is.Equal(t, 0, len(feed.Entry))

      
        106
        106
         }

      
        107
        107
         

      
        108
        108
         func mustReadFixture(t *testing.T, name string) []byte {