Lasa is a stateless proxy that generates a RSS or an Atom feed from a Standard.site publication.
lasa.anhgelus.world
rss
atom
atprotocol
standard-site
atproto
1package lasa
2
3import (
4 "context"
5 "embed"
6 "html"
7 "io"
8 "reflect"
9 "text/template"
10 "time"
11
12 site "tangled.org/anhgelus.world/goat-site"
13 "tangled.org/anhgelus.world/xrpc"
14 "tangled.org/anhgelus.world/xrpc/atproto"
15)
16
17//go:embed rss.xml
18var rssTemplate embed.FS
19
20type FeedItem struct {
21 Title string
22 Link string
23 Description string
24 PubDate string
25 UpdatedDate string
26 Author string
27 Categories []string
28}
29
30type FeedData struct {
31 // required
32 Title string
33 Link string
34 Description string
35 // optional
36 LastBuildDate string
37 Items []FeedItem
38 Author string
39}
40
41type ErrCannotGenerateFeed struct {
42 v string
43}
44
45func (err ErrCannotGenerateFeed) Error() string {
46 return "cannot generate RSS: " + err.v
47}
48
49func GenerateRSS(
50 ctx context.Context,
51 client xrpc.Client,
52 w io.Writer,
53 author *atproto.DID,
54 pub xrpc.RecordStored[*site.Publication],
55) error {
56 if pub.Value.Description == nil {
57 return ErrCannotGenerateFeed{"description is not set"}
58 }
59 data, err := genFeedData(ctx, client, author, pub)
60 if err != nil {
61 return err
62 }
63 return template.Must(template.New("rss").Funcs(map[string]any{
64 "isSet": IsSet,
65 }).ParseFS(rssTemplate, "rss.xml")).ExecuteTemplate(w, "rss.xml", data)
66}
67
68func genFeedData(
69 ctx context.Context,
70 client xrpc.Client,
71 author *atproto.DID,
72 pub xrpc.RecordStored[*site.Publication],
73) (FeedData, error) {
74 data := FeedData{
75 Title: html.EscapeString(pub.Value.Name),
76 Link: pub.Value.URL.String(),
77 }
78 if pub.Value.Description != nil {
79 data.Description = *pub.Value.Description
80 }
81 items, err := ListDocuments(ctx, client, author, pub.URI)
82 if err != nil {
83 return data, err
84 }
85 doc, err := client.Directory().ResolveDID(ctx, author)
86 if err != nil {
87 return data, err
88 }
89 handle, ok := doc.Handle()
90 if ok {
91 data.Author = handle.String()
92 }
93 data.Items = make([]FeedItem, len(items))
94 for i, item := range items {
95 url := pub.Value.URL
96 if item.Value.Path == nil {
97 return data, ErrCannotGenerateFeed{"path is not set for " + item.Value.Title}
98 }
99 url.Path = *item.Value.Path
100 for i, v := range item.Value.Tags {
101 item.Value.Tags[i] = html.EscapeString(v)
102 }
103 d := FeedItem{
104 Link: url.String(),
105 Title: html.EscapeString(item.Value.Title),
106 PubDate: item.Value.PublishedAt.Format(time.RFC1123),
107 Categories: item.Value.Tags,
108 }
109 if ok {
110 d.Author = "@" + handle.String()
111 }
112 if item.Value.Description != nil {
113 d.Description = html.EscapeString(*item.Value.Description)
114 }
115 if item.Value.UpdatedAt != nil {
116 d.UpdatedDate = item.Value.UpdatedAt.Format(time.RFC1123)
117 } else {
118 d.UpdatedDate = d.PubDate
119 }
120 if i == 0 {
121 data.LastBuildDate = d.UpdatedDate
122 }
123 data.Items[i] = d
124 }
125 return data, nil
126}
127
128func IsSet(v any) bool {
129 return !reflect.ValueOf(v).IsZero()
130}