feat: init a8b6eff8
Steve Simkins · 2026-07-05 09:41 12 file(s) · +1314 −0
.gitignore (added) +1 −0
1 +
feeds
app.go (added) +92 −0
1 +
package main
2 +
3 +
import (
4 +
	"embed"
5 +
	"encoding/json"
6 +
	"html/template"
7 +
	"log/slog"
8 +
	"mime"
9 +
	"net/http"
10 +
	"os"
11 +
	"path/filepath"
12 +
	"strings"
13 +
)
14 +
15 +
//go:embed templates/*.html static/*
16 +
var appFS embed.FS
17 +
18 +
type App struct {
19 +
	Log       *slog.Logger
20 +
	Templates *template.Template
21 +
	BaseURL   string
22 +
}
23 +
24 +
type templateItem struct {
25 +
	Title         string
26 +
	Link          string
27 +
	Author        string
28 +
	FormattedDate string
29 +
}
30 +
31 +
type indexPageData struct {
32 +
	BaseURL  string
33 +
	Items    []templateItem
34 +
	FeedURLs []string
35 +
	Error    string
36 +
}
37 +
38 +
func (a *App) routes() *http.ServeMux {
39 +
	mux := http.NewServeMux()
40 +
	mux.HandleFunc("GET /", a.indexHandler)
41 +
	mux.HandleFunc("GET /api/resolve", a.resolveHandler)
42 +
	mux.HandleFunc("GET /static/", embeddedHandler(appFS, "static"))
43 +
	return mux
44 +
}
45 +
46 +
// embeddedHandler serves files from an embed.FS under the given URL prefix.
47 +
func embeddedHandler(fs embed.FS, prefix string) http.HandlerFunc {
48 +
	return func(w http.ResponseWriter, r *http.Request) {
49 +
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
50 +
		path := filepath.ToSlash(filepath.Join(prefix, name))
51 +
		data, err := fs.ReadFile(path)
52 +
		if err != nil {
53 +
			http.NotFound(w, r)
54 +
			return
55 +
		}
56 +
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
57 +
			w.Header().Set("Content-Type", ct)
58 +
		}
59 +
		_, _ = w.Write(data)
60 +
	}
61 +
}
62 +
63 +
// render executes a named template into w. Errors are logged and surfaced as HTTP 500.
64 +
func render(t *template.Template, w http.ResponseWriter, name string, data any, log *slog.Logger) {
65 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
66 +
	if err := t.ExecuteTemplate(w, name, data); err != nil {
67 +
		if log != nil {
68 +
			log.Error("template render failed", "name", name, "err", err)
69 +
		}
70 +
		http.Error(w, "template error", http.StatusInternalServerError)
71 +
	}
72 +
}
73 +
74 +
// writeJSON writes data as JSON with the given status code.
75 +
func writeJSON(w http.ResponseWriter, status int, data any) {
76 +
	w.Header().Set("Content-Type", "application/json")
77 +
	w.WriteHeader(status)
78 +
	_ = json.NewEncoder(w).Encode(data)
79 +
}
80 +
81 +
// writeError writes a JSON error response of the form {"error": msg}.
82 +
func writeError(w http.ResponseWriter, status int, msg string) {
83 +
	writeJSON(w, status, map[string]any{"error": msg})
84 +
}
85 +
86 +
// getenv returns the trimmed value of key or fallback when unset/blank.
87 +
func getenv(key, fallback string) string {
88 +
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
89 +
		return v
90 +
	}
91 +
	return fallback
92 +
}
feeds.go (added) +398 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"errors"
6 +
	"fmt"
7 +
	"io"
8 +
	"log/slog"
9 +
	"net/http"
10 +
	"net/url"
11 +
	"slices"
12 +
	"strings"
13 +
	"sync"
14 +
	"time"
15 +
	"unicode/utf8"
16 +
17 +
	"github.com/mmcdole/gofeed"
18 +
	"golang.org/x/net/html"
19 +
)
20 +
21 +
type ParsedEntry struct {
22 +
	GUID        string
23 +
	Title       string
24 +
	Link        string
25 +
	Author      string
26 +
	PublishedAt int64
27 +
}
28 +
29 +
type FetchResult struct {
30 +
	Status       int
31 +
	ETag         string
32 +
	LastModified string
33 +
	Title        string
34 +
	SiteURL      string
35 +
	Entries      []ParsedEntry
36 +
}
37 +
38 +
type FeedPreviewItem struct {
39 +
	Title     string
40 +
	Link      string
41 +
	Author    string
42 +
	Published int64
43 +
}
44 +
45 +
const appUserAgent = "feeds/0.1 (+https://github.com/stevedylandev/feeds)"
46 +
47 +
func buildHTTPClient() *http.Client {
48 +
	return &http.Client{Timeout: 15 * time.Second}
49 +
}
50 +
51 +
func newRequest(ctx context.Context, method, rawURL string) (*http.Request, error) {
52 +
	req, err := http.NewRequestWithContext(ctx, method, rawURL, nil)
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	req.Header.Set("User-Agent", appUserAgent)
57 +
	return req, nil
58 +
}
59 +
60 +
func fetchFeed(ctx context.Context, feedURL, etag, lastModified string) (*FetchResult, error) {
61 +
	client := buildHTTPClient()
62 +
	req, err := newRequest(ctx, http.MethodGet, feedURL)
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	if etag != "" {
67 +
		req.Header.Set("If-None-Match", etag)
68 +
	}
69 +
	if lastModified != "" {
70 +
		req.Header.Set("If-Modified-Since", lastModified)
71 +
	}
72 +
	resp, err := client.Do(req)
73 +
	if err != nil {
74 +
		return nil, fmt.Errorf("fetch failed: %w", err)
75 +
	}
76 +
	defer resp.Body.Close()
77 +
	result := &FetchResult{
78 +
		Status:       resp.StatusCode,
79 +
		ETag:         resp.Header.Get("ETag"),
80 +
		LastModified: resp.Header.Get("Last-Modified"),
81 +
	}
82 +
	if resp.StatusCode == http.StatusNotModified {
83 +
		if result.ETag == "" {
84 +
			result.ETag = etag
85 +
		}
86 +
		if result.LastModified == "" {
87 +
			result.LastModified = lastModified
88 +
		}
89 +
		return result, nil
90 +
	}
91 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
92 +
		return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
93 +
	}
94 +
	parser := gofeed.NewParser()
95 +
	feed, err := parser.Parse(resp.Body)
96 +
	if err != nil {
97 +
		return nil, fmt.Errorf("feed parse failed: %w", err)
98 +
	}
99 +
	result.Title = strings.TrimSpace(feed.Title)
100 +
	result.SiteURL = firstNonEmpty(feed.Link, firstFeedAltLink(feed))
101 +
	for _, item := range feed.Items {
102 +
		link := strings.TrimSpace(item.Link)
103 +
		if link == "" {
104 +
			continue
105 +
		}
106 +
		title := strings.TrimSpace(item.Title)
107 +
		if title == "" {
108 +
			title = deriveTitleFromHTML(firstNonEmpty(item.Description, item.Content))
109 +
		}
110 +
		author := ""
111 +
		if item.Author != nil {
112 +
			author = strings.TrimSpace(item.Author.Name)
113 +
		}
114 +
		guid := strings.TrimSpace(item.GUID)
115 +
		if guid == "" {
116 +
			guid = link
117 +
		}
118 +
		published := int64(0)
119 +
		switch {
120 +
		case item.PublishedParsed != nil:
121 +
			published = item.PublishedParsed.Unix()
122 +
		case item.UpdatedParsed != nil:
123 +
			published = item.UpdatedParsed.Unix()
124 +
		}
125 +
		result.Entries = append(result.Entries, ParsedEntry{
126 +
			GUID:        guid,
127 +
			Title:       title,
128 +
			Link:        link,
129 +
			Author:      author,
130 +
			PublishedAt: published,
131 +
		})
132 +
	}
133 +
	return result, nil
134 +
}
135 +
136 +
func deriveTitleFromHTML(src string) string {
137 +
	txt := strings.Join(strings.Fields(htmlToText(src)), " ")
138 +
	if txt == "" {
139 +
		return ""
140 +
	}
141 +
	const maxChars = 80
142 +
	if utf8.RuneCountInString(txt) <= maxChars {
143 +
		return txt
144 +
	}
145 +
	runes := []rune(txt)
146 +
	return strings.TrimSpace(string(runes[:maxChars])) + "…"
147 +
}
148 +
149 +
func htmlToText(src string) string {
150 +
	if strings.TrimSpace(src) == "" {
151 +
		return ""
152 +
	}
153 +
	node, err := html.Parse(strings.NewReader(src))
154 +
	if err != nil {
155 +
		return src
156 +
	}
157 +
	var b strings.Builder
158 +
	var walk func(*html.Node)
159 +
	walk = func(n *html.Node) {
160 +
		if n.Type == html.TextNode {
161 +
			b.WriteString(n.Data)
162 +
			b.WriteByte(' ')
163 +
		}
164 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
165 +
			walk(c)
166 +
		}
167 +
	}
168 +
	walk(node)
169 +
	return html.UnescapeString(b.String())
170 +
}
171 +
172 +
func previewURLs(ctx context.Context, urls []string, perFeed int, log *slog.Logger) []FeedPreviewItem {
173 +
	var wg sync.WaitGroup
174 +
	var mu sync.Mutex
175 +
	items := []FeedPreviewItem{}
176 +
	for _, raw := range urls {
177 +
		feedURL := strings.TrimSpace(raw)
178 +
		if feedURL == "" {
179 +
			continue
180 +
		}
181 +
		wg.Add(1)
182 +
		go func() {
183 +
			defer wg.Done()
184 +
			res, err := fetchFeed(ctx, feedURL, "", "")
185 +
			if err != nil {
186 +
				log.Warn("preview fetch failed", "url", feedURL, "err", err)
187 +
				return
188 +
			}
189 +
			feedTitle := res.Title
190 +
			local := make([]FeedPreviewItem, 0, len(res.Entries))
191 +
			for _, entry := range res.Entries {
192 +
				if perFeed > 0 && len(local) >= perFeed {
193 +
					break
194 +
				}
195 +
				author := feedTitle
196 +
				if entry.Author != "" && feedTitle != "" {
197 +
					author = feedTitle + " - " + entry.Author
198 +
				} else if entry.Author != "" {
199 +
					author = entry.Author
200 +
				}
201 +
				local = append(local, FeedPreviewItem{Title: entry.Title, Link: entry.Link, Author: author, Published: entry.PublishedAt})
202 +
			}
203 +
			mu.Lock()
204 +
			items = append(items, local...)
205 +
			mu.Unlock()
206 +
		}()
207 +
	}
208 +
	wg.Wait()
209 +
	slices.SortFunc(items, func(a, b FeedPreviewItem) int {
210 +
		switch {
211 +
		case a.Published > b.Published:
212 +
			return -1
213 +
		case a.Published < b.Published:
214 +
			return 1
215 +
		default:
216 +
			return 0
217 +
		}
218 +
	})
219 +
	return items
220 +
}
221 +
222 +
func discoverFavicon(ctx context.Context, siteURL string) string {
223 +
	parsed, err := url.Parse(siteURL)
224 +
	if err != nil {
225 +
		return ""
226 +
	}
227 +
	client := buildHTTPClient()
228 +
	req, err := newRequest(ctx, http.MethodGet, siteURL)
229 +
	if err != nil {
230 +
		return ""
231 +
	}
232 +
	resp, err := client.Do(req)
233 +
	if err == nil {
234 +
		defer resp.Body.Close()
235 +
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
236 +
		if href := findLinkHref(string(body), func(rel, typ string) bool {
237 +
			rel = strings.ToLower(rel)
238 +
			return strings.Contains(rel, "icon")
239 +
		}); href != "" {
240 +
			if resolved, err := parsed.Parse(href); err == nil {
241 +
				return resolved.String()
242 +
			}
243 +
		}
244 +
	}
245 +
	if fallback, err := parsed.Parse("/favicon.ico"); err == nil {
246 +
		return fallback.String()
247 +
	}
248 +
	return ""
249 +
}
250 +
251 +
func findLinkHref(doc string, match func(rel, typ string) bool) string {
252 +
	node, err := html.Parse(strings.NewReader(doc))
253 +
	if err != nil {
254 +
		return ""
255 +
	}
256 +
	var found string
257 +
	var walk func(*html.Node)
258 +
	walk = func(n *html.Node) {
259 +
		if found != "" {
260 +
			return
261 +
		}
262 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") {
263 +
			attrs := attrsMap(n)
264 +
			if match(attrs["rel"], attrs["type"]) {
265 +
				found = attrs["href"]
266 +
				return
267 +
			}
268 +
		}
269 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
270 +
			walk(c)
271 +
		}
272 +
	}
273 +
	walk(node)
274 +
	return found
275 +
}
276 +
277 +
func discoverFeeds(ctx context.Context, baseURL string) ([]string, error) {
278 +
	parsed, err := url.Parse(baseURL)
279 +
	if err != nil {
280 +
		return nil, fmt.Errorf("invalid URL: %w", err)
281 +
	}
282 +
	client := buildHTTPClient()
283 +
	req, err := newRequest(ctx, http.MethodGet, baseURL)
284 +
	if err != nil {
285 +
		return nil, fmt.Errorf("invalid URL: %w", err)
286 +
	}
287 +
	feeds := []string{}
288 +
	resp, err := client.Do(req)
289 +
	if err == nil {
290 +
		defer resp.Body.Close()
291 +
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
292 +
		links := findAlternateFeedLinks(string(body))
293 +
		for _, href := range links {
294 +
			resolved := href
295 +
			if u, err := parsed.Parse(href); err == nil {
296 +
				resolved = u.String()
297 +
			}
298 +
			if !slices.Contains(feeds, resolved) {
299 +
				feeds = append(feeds, resolved)
300 +
			}
301 +
		}
302 +
	}
303 +
	if len(feeds) == 0 {
304 +
		paths := []string{"/feed", "/feed.xml", "/rss", "/rss.xml", "/atom.xml", "/index.xml", "/feed/rss", "/blog/feed", "/blog/rss"}
305 +
		for _, path := range paths {
306 +
			probe, err := parsed.Parse(path)
307 +
			if err != nil {
308 +
				continue
309 +
			}
310 +
			req, err := newRequest(ctx, http.MethodHead, probe.String())
311 +
			if err != nil {
312 +
				continue
313 +
			}
314 +
			resp, err := client.Do(req)
315 +
			if err != nil {
316 +
				continue
317 +
			}
318 +
			_ = resp.Body.Close()
319 +
			ct := strings.ToLower(resp.Header.Get("Content-Type"))
320 +
			if resp.StatusCode >= 200 && resp.StatusCode < 300 && (strings.Contains(ct, "xml") || strings.Contains(ct, "rss") || strings.Contains(ct, "atom")) {
321 +
				feeds = append(feeds, probe.String())
322 +
			}
323 +
		}
324 +
	}
325 +
	if len(feeds) == 0 {
326 +
		return nil, errors.New("no feeds found at this URL")
327 +
	}
328 +
	return feeds, nil
329 +
}
330 +
331 +
func findAlternateFeedLinks(doc string) []string {
332 +
	node, err := html.Parse(strings.NewReader(doc))
333 +
	if err != nil {
334 +
		return nil
335 +
	}
336 +
	links := []string{}
337 +
	var walk func(*html.Node)
338 +
	walk = func(n *html.Node) {
339 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") {
340 +
			attrs := attrsMap(n)
341 +
			rel := strings.ToLower(attrs["rel"])
342 +
			typ := strings.ToLower(attrs["type"])
343 +
			href := attrs["href"]
344 +
			if strings.Contains(rel, "alternate") && href != "" && (strings.Contains(typ, "rss") || strings.Contains(typ, "atom") || strings.Contains(typ, "xml")) {
345 +
				links = append(links, href)
346 +
			}
347 +
		}
348 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
349 +
			walk(c)
350 +
		}
351 +
	}
352 +
	walk(node)
353 +
	return links
354 +
}
355 +
356 +
func attrsMap(n *html.Node) map[string]string {
357 +
	out := make(map[string]string, len(n.Attr))
358 +
	for _, a := range n.Attr {
359 +
		out[strings.ToLower(a.Key)] = a.Val
360 +
	}
361 +
	return out
362 +
}
363 +
364 +
func firstFeedAltLink(feed *gofeed.Feed) string {
365 +
	for _, link := range feed.Links {
366 +
		if strings.TrimSpace(link) != "" {
367 +
			return link
368 +
		}
369 +
	}
370 +
	return ""
371 +
}
372 +
373 +
func firstNonEmpty(values ...string) string {
374 +
	for _, v := range values {
375 +
		if strings.TrimSpace(v) != "" {
376 +
			return strings.TrimSpace(v)
377 +
		}
378 +
	}
379 +
	return ""
380 +
}
381 +
382 +
func formatDate(ts int64) string {
383 +
	if ts <= 0 {
384 +
		return ""
385 +
	}
386 +
	return time.Unix(ts, 0).UTC().Format("Jan 2, 2006")
387 +
}
388 +
389 +
func splitAndTrim(s string) []string {
390 +
	parts := strings.Split(s, ",")
391 +
	out := []string{}
392 +
	for _, part := range parts {
393 +
		if trimmed := strings.TrimSpace(part); trimmed != "" {
394 +
			out = append(out, trimmed)
395 +
		}
396 +
	}
397 +
	return out
398 +
}
go.mod (added) +19 −0
1 +
module github.com/stevedylandev/feeds
2 +
3 +
go 1.25.0
4 +
5 +
require (
6 +
	github.com/mmcdole/gofeed v1.3.0
7 +
	golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2
8 +
	golang.org/x/net v0.41.0
9 +
)
10 +
11 +
require (
12 +
	github.com/PuerkitoBio/goquery v1.8.0 // indirect
13 +
	github.com/andybalholm/cascadia v1.3.1 // indirect
14 +
	github.com/json-iterator/go v1.1.12 // indirect
15 +
	github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
16 +
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
17 +
	github.com/modern-go/reflect2 v1.0.2 // indirect
18 +
	golang.org/x/text v0.26.0 // indirect
19 +
)
go.sum (added) +39 −0
1 +
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
2 +
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
3 +
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
4 +
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
5 +
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 +
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 +
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 +
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
9 +
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
10 +
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
11 +
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
12 +
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
13 +
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
14 +
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
15 +
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
16 +
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
17 +
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
18 +
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
19 +
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
20 +
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
21 +
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
22 +
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
23 +
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
24 +
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
25 +
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
26 +
golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2 h1:7Y5FZkvYs5XMyG0VS/pONmKIgD9+9eqcm1DGar541SA=
27 +
golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
28 +
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
29 +
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
30 +
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
31 +
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
32 +
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
33 +
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
34 +
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
35 +
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
36 +
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
37 +
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
38 +
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
39 +
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
handlers.go (added) +127 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"net/http"
6 +
	"net/url"
7 +
	"strings"
8 +
	"sync"
9 +
	"time"
10 +
)
11 +
12 +
const maxFeedURLs = 20
13 +
14 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
15 +
	query := r.URL.Query().Get("url")
16 +
	if query == "" {
17 +
		query = r.URL.Query().Get("urls")
18 +
	}
19 +
	data := indexPageData{BaseURL: a.BaseURL}
20 +
	if query == "" {
21 +
		render(a.Templates, w, "index.html", data, a.Log)
22 +
		return
23 +
	}
24 +
25 +
	urls := splitAndTrim(query)
26 +
	if len(urls) == 0 {
27 +
		render(a.Templates, w, "index.html", data, a.Log)
28 +
		return
29 +
	}
30 +
	if len(urls) > maxFeedURLs {
31 +
		urls = urls[:maxFeedURLs]
32 +
	}
33 +
	data.FeedURLs = urls
34 +
35 +
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
36 +
	defer cancel()
37 +
	for _, item := range previewURLs(ctx, urls, 0, a.Log) {
38 +
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
39 +
	}
40 +
	if len(data.Items) == 0 {
41 +
		data.Error = "No items could be loaded from these feeds"
42 +
	}
43 +
	render(a.Templates, w, "index.html", data, a.Log)
44 +
}
45 +
46 +
type resolvedFeed struct {
47 +
	URL     string `json:"url"`
48 +
	Title   string `json:"title"`
49 +
	Favicon string `json:"favicon,omitempty"`
50 +
}
51 +
52 +
// faviconFor discovers the favicon for a fetched feed, falling back to the
53 +
// feed URL's origin when the feed doesn't declare a site link.
54 +
func faviconFor(ctx context.Context, res *FetchResult, feedURL string) string {
55 +
	site := res.SiteURL
56 +
	if site == "" {
57 +
		if u, err := url.Parse(feedURL); err == nil && u.Host != "" {
58 +
			site = u.Scheme + "://" + u.Host
59 +
		}
60 +
	}
61 +
	if site == "" {
62 +
		return ""
63 +
	}
64 +
	return discoverFavicon(ctx, site)
65 +
}
66 +
67 +
func (a *App) resolveHandler(w http.ResponseWriter, r *http.Request) {
68 +
	raw := strings.TrimSpace(r.URL.Query().Get("url"))
69 +
	if raw == "" {
70 +
		writeError(w, http.StatusBadRequest, "url parameter is required")
71 +
		return
72 +
	}
73 +
	if !strings.Contains(raw, "://") {
74 +
		raw = "https://" + raw
75 +
	}
76 +
77 +
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
78 +
	defer cancel()
79 +
80 +
	// If the input parses as a feed, use it directly.
81 +
	if res, err := fetchFeed(ctx, raw, "", ""); err == nil {
82 +
		writeJSON(w, http.StatusOK, map[string]any{"feeds": []resolvedFeed{{URL: raw, Title: res.Title, Favicon: faviconFor(ctx, res, raw)}}})
83 +
		return
84 +
	}
85 +
86 +
	// Otherwise treat it as a site URL and discover feeds.
87 +
	candidates, err := discoverFeeds(ctx, raw)
88 +
	if err != nil {
89 +
		writeError(w, http.StatusUnprocessableEntity, "no feed found at this URL")
90 +
		return
91 +
	}
92 +
	if len(candidates) > 3 {
93 +
		candidates = candidates[:3]
94 +
	}
95 +
96 +
	var wg sync.WaitGroup
97 +
	var mu sync.Mutex
98 +
	found := map[string]resolvedFeed{}
99 +
	for _, candidate := range candidates {
100 +
		wg.Add(1)
101 +
		go func() {
102 +
			defer wg.Done()
103 +
			res, err := fetchFeed(ctx, candidate, "", "")
104 +
			if err != nil {
105 +
				a.Log.Warn("resolve candidate failed", "url", candidate, "err", err)
106 +
				return
107 +
			}
108 +
			favicon := faviconFor(ctx, res, candidate)
109 +
			mu.Lock()
110 +
			found[candidate] = resolvedFeed{URL: candidate, Title: res.Title, Favicon: favicon}
111 +
			mu.Unlock()
112 +
		}()
113 +
	}
114 +
	wg.Wait()
115 +
116 +
	feeds := make([]resolvedFeed, 0, len(found))
117 +
	for _, candidate := range candidates {
118 +
		if f, ok := found[candidate]; ok {
119 +
			feeds = append(feeds, f)
120 +
		}
121 +
	}
122 +
	if len(feeds) == 0 {
123 +
		writeError(w, http.StatusUnprocessableEntity, "no feed found at this URL")
124 +
		return
125 +
	}
126 +
	writeJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
127 +
}
main.go (added) +28 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
10 +
	_ "golang.org/x/crypto/x509roots/fallback"
11 +
)
12 +
13 +
func main() {
14 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
15 +
16 +
	tmpl := template.Must(template.New("").ParseFS(appFS, "templates/*.html"))
17 +
	app := &App{
18 +
		Log:       logger,
19 +
		Templates: tmpl,
20 +
		BaseURL:   getenv("BASE_URL", "http://localhost:3000"),
21 +
	}
22 +
23 +
	addr := getenv("HOST", "0.0.0.0") + ":" + getenv("PORT", "3000")
24 +
	logger.Info("feeds server running", "addr", addr)
25 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
26 +
		log.Fatal(err)
27 +
	}
28 +
}
static/app.js (added) +147 −0
1 +
// Builder UI for the root page. All persistent state lives in the URL —
2 +
// the pending list here only exists until the user presses Go.
3 +
4 +
// Example feeds. Favicon is optional; when omitted it falls back to the
5 +
// site's /favicon.ico.
6 +
const EXAMPLE_FEEDS = [
7 +
  {
8 +
    title: "Bubbles",
9 +
    url: "https://bubbles.town/feed",
10 +
    favicon: "https://bubbles.town/static/favicon-32.png",
11 +
  },
12 +
  {
13 +
    title: "Hacker News",
14 +
    url: "https://news.ycombinator.com/rss",
15 +
    favicon: "https://news.ycombinator.com/y18.svg",
16 +
  },
17 +
  {
18 +
    title: "Jared Henderson",
19 +
    url: "https://www.youtube.com/feeds/videos.xml?channel_id=UC2Kyj04yISmHr1V-UlJz4eg",
20 +
    favicon: "https://www.youtube.com/favicon.ico",
21 +
  },
22 +
  {
23 +
    title: "NYT Top Stories",
24 +
    url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
25 +
    favicon: "https://www.nytimes.com/favicon.ico",
26 +
  },
27 +
];
28 +
29 +
function fallbackFavicon(feedURL) {
30 +
  try {
31 +
    return new URL(feedURL).origin + "/favicon.ico";
32 +
  } catch {
33 +
    return "";
34 +
  }
35 +
}
36 +
37 +
function faviconImg(src) {
38 +
  const img = document.createElement("img");
39 +
  img.className = "favicon";
40 +
  img.width = 16;
41 +
  img.height = 16;
42 +
  img.alt = "";
43 +
  img.src = src;
44 +
  img.addEventListener("error", () => img.remove());
45 +
  return img;
46 +
}
47 +
48 +
const input = document.getElementById("feed-input");
49 +
const addButton = document.getElementById("add-button");
50 +
const goButton = document.getElementById("go-button");
51 +
const status = document.getElementById("status");
52 +
const examples = document.getElementById("examples");
53 +
const pendingList = document.getElementById("pending");
54 +
55 +
const pending = [];
56 +
57 +
function setStatus(text, spinning) {
58 +
  status.textContent = text;
59 +
  status.classList.toggle("hidden", !text && !spinning);
60 +
  status.classList.toggle("spinner", Boolean(spinning));
61 +
}
62 +
63 +
function renderPending() {
64 +
  pendingList.innerHTML = "";
65 +
  for (const feed of pending) {
66 +
    const li = document.createElement("li");
67 +
    const favicon = feed.favicon || fallbackFavicon(feed.url);
68 +
    if (favicon) li.appendChild(faviconImg(favicon));
69 +
    const label = document.createElement("span");
70 +
    label.className = "pending-label";
71 +
    label.textContent = feed.title || feed.url;
72 +
    const url = document.createElement("span");
73 +
    url.className = "pending-url";
74 +
    url.textContent = feed.url;
75 +
    const remove = document.createElement("button");
76 +
    remove.className = "link-button danger";
77 +
    remove.textContent = "×";
78 +
    remove.title = "Remove feed";
79 +
    remove.addEventListener("click", () => {
80 +
      pending.splice(pending.indexOf(feed), 1);
81 +
      renderPending();
82 +
    });
83 +
    li.append(label, url, remove);
84 +
    pendingList.appendChild(li);
85 +
  }
86 +
  goButton.disabled = pending.length === 0;
87 +
}
88 +
89 +
function addFeed(url, title, favicon) {
90 +
  if (pending.some((f) => f.url === url)) {
91 +
    setStatus("already added", false);
92 +
    return;
93 +
  }
94 +
  pending.push({ url, title, favicon });
95 +
  setStatus("", false);
96 +
  renderPending();
97 +
}
98 +
99 +
async function resolve() {
100 +
  const value = input.value.trim();
101 +
  if (!value) return;
102 +
  input.disabled = true;
103 +
  addButton.disabled = true;
104 +
  setStatus("finding feed", true);
105 +
  try {
106 +
    const resp = await fetch("/api/resolve?url=" + encodeURIComponent(value));
107 +
    const body = await resp.json();
108 +
    if (!resp.ok) {
109 +
      setStatus(body.error || "could not resolve feed", false);
110 +
      return;
111 +
    }
112 +
    for (const feed of body.feeds) {
113 +
      addFeed(feed.url, feed.title, feed.favicon);
114 +
    }
115 +
    input.value = "";
116 +
  } catch {
117 +
    setStatus("could not resolve feed", false);
118 +
  } finally {
119 +
    input.disabled = false;
120 +
    addButton.disabled = false;
121 +
    input.focus();
122 +
  }
123 +
}
124 +
125 +
addButton.addEventListener("click", resolve);
126 +
input.addEventListener("keydown", (e) => {
127 +
  if (e.key === "Enter") {
128 +
    e.preventDefault();
129 +
    resolve();
130 +
  }
131 +
});
132 +
133 +
goButton.addEventListener("click", () => {
134 +
  if (pending.length === 0) return;
135 +
  location.href = "/?url=" + pending.map((f) => encodeURIComponent(f.url)).join(",");
136 +
});
137 +
138 +
for (const feed of EXAMPLE_FEEDS) {
139 +
  const chip = document.createElement("button");
140 +
  chip.className = "chip";
141 +
  const favicon = feed.favicon || fallbackFavicon(feed.url);
142 +
  if (favicon) chip.appendChild(faviconImg(favicon));
143 +
  chip.appendChild(document.createTextNode(feed.title));
144 +
  chip.title = feed.url;
145 +
  chip.addEventListener("click", () => addFeed(feed.url, feed.title, feed.favicon));
146 +
  examples.appendChild(chip);
147 +
}
static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

static/styles.css (added) +403 −0
1 +
/* feeds — self-contained styles.
2 +
 * Black-on-white inversion of the darkmatter design system.
3 +
 */
4 +
5 +
@font-face {
6 +
  font-family: "Commit Mono";
7 +
  src: url("/static/fonts/CommitMono-400-Regular.otf") format("opentype");
8 +
  font-weight: 400;
9 +
  font-style: normal;
10 +
  font-display: swap;
11 +
}
12 +
13 +
@font-face {
14 +
  font-family: "Commit Mono";
15 +
  src: url("/static/fonts/CommitMono-700-Regular.otf") format("opentype");
16 +
  font-weight: 700;
17 +
  font-style: normal;
18 +
  font-display: swap;
19 +
}
20 +
21 +
/* ── Reset + webkit hardening ─────────────────────────────────────── */
22 +
23 +
*,
24 +
*::before,
25 +
*::after {
26 +
  padding: 0;
27 +
  margin: 0;
28 +
  box-sizing: border-box;
29 +
  font-family: "Commit Mono", monospace, sans-serif;
30 +
  -webkit-tap-highlight-color: transparent;
31 +
}
32 +
33 +
* {
34 +
  scrollbar-width: none;
35 +
  -ms-overflow-style: none;
36 +
}
37 +
38 +
html {
39 +
  background: #ffffff;
40 +
  color: #1a1a1a;
41 +
  font-size: 14px;
42 +
  line-height: 1.6;
43 +
  -webkit-text-size-adjust: 100%;
44 +
  text-size-adjust: 100%;
45 +
}
46 +
47 +
html::-webkit-scrollbar {
48 +
  display: none;
49 +
}
50 +
51 +
body {
52 +
  display: flex;
53 +
  flex-direction: column;
54 +
  justify-content: start;
55 +
  align-items: start;
56 +
  gap: 1.5rem;
57 +
  min-height: 100vh;
58 +
  max-width: 700px;
59 +
  margin: auto;
60 +
  padding: 0 1rem 4rem;
61 +
}
62 +
63 +
@media (max-width: 480px) {
64 +
  body {
65 +
    padding: 1rem;
66 +
    gap: 1rem;
67 +
  }
68 +
}
69 +
70 +
/* ── Links ────────────────────────────────────────────────────────── */
71 +
72 +
a {
73 +
  color: #1a1a1a;
74 +
  text-decoration: none;
75 +
  touch-action: manipulation;
76 +
}
77 +
78 +
a:hover {
79 +
  opacity: 0.7;
80 +
}
81 +
82 +
/* ── Header / nav ─────────────────────────────────────────────────── */
83 +
84 +
.header {
85 +
  display: flex;
86 +
  flex-direction: column;
87 +
  gap: 0.5rem;
88 +
  width: 100%;
89 +
  margin-top: 2rem;
90 +
  border-bottom: 1px solid #ddd;
91 +
  padding-bottom: 1rem;
92 +
}
93 +
94 +
.logo {
95 +
  font-size: 28px;
96 +
  font-weight: 700;
97 +
  text-decoration: none;
98 +
  text-transform: uppercase;
99 +
}
100 +
101 +
.logo h1 {
102 +
  font-size: 28px;
103 +
  font-weight: 700;
104 +
  text-transform: uppercase;
105 +
}
106 +
107 +
.links {
108 +
  display: flex;
109 +
  align-items: center;
110 +
  gap: 0.75rem;
111 +
  font-size: 12px;
112 +
}
113 +
114 +
/* ── Forms ────────────────────────────────────────────────────────── */
115 +
116 +
label {
117 +
  font-size: 12px;
118 +
  opacity: 0.7;
119 +
}
120 +
121 +
input,
122 +
textarea,
123 +
select {
124 +
  background: #ffffff;
125 +
  color: #1a1a1a;
126 +
  border: 1px solid #1a1a1a;
127 +
  padding: 0.4rem 0.75rem;
128 +
  font-size: 16px; /* 16px prevents iOS focus zoom */
129 +
  width: 100%;
130 +
  border-radius: 0;
131 +
  -webkit-appearance: none;
132 +
  appearance: none;
133 +
  outline: none;
134 +
}
135 +
136 +
input:focus,
137 +
textarea:focus,
138 +
select:focus {
139 +
  outline: none;
140 +
}
141 +
142 +
/* ── Buttons ──────────────────────────────────────────────────────── */
143 +
144 +
button,
145 +
.btn {
146 +
  background: #ffffff;
147 +
  color: #1a1a1a;
148 +
  padding: 0.2rem 0.75rem;
149 +
  border: 1px solid #1a1a1a;
150 +
  cursor: pointer;
151 +
  width: fit-content;
152 +
  font-size: 14px;
153 +
  line-height: 1.4;
154 +
  border-radius: 0;
155 +
  -webkit-appearance: none;
156 +
  appearance: none;
157 +
  text-decoration: none;
158 +
  display: inline-block;
159 +
  touch-action: manipulation;
160 +
}
161 +
162 +
button:hover,
163 +
.btn:hover {
164 +
  opacity: 0.7;
165 +
}
166 +
167 +
button:disabled {
168 +
  opacity: 0.3;
169 +
  cursor: default;
170 +
}
171 +
172 +
button.loading {
173 +
  cursor: wait;
174 +
}
175 +
176 +
.link-button {
177 +
  background: none;
178 +
  border: none;
179 +
  color: #1a1a1a;
180 +
  cursor: pointer;
181 +
  font-size: 12px;
182 +
  padding: 0;
183 +
  font-family: inherit;
184 +
  -webkit-appearance: none;
185 +
  appearance: none;
186 +
}
187 +
188 +
.link-button:hover {
189 +
  opacity: 0.7;
190 +
}
191 +
192 +
.link-button.danger {
193 +
  opacity: 0.5;
194 +
}
195 +
196 +
.link-button.danger:hover {
197 +
  opacity: 0.3;
198 +
}
199 +
200 +
/* ── Feedback ─────────────────────────────────────────────────────── */
201 +
202 +
.error {
203 +
  color: #1a1a1a;
204 +
  border-left: 2px solid #1a1a1a;
205 +
  padding-left: 0.5rem;
206 +
  font-size: 13px;
207 +
  opacity: 0.8;
208 +
}
209 +
210 +
.hint {
211 +
  font-size: 12px;
212 +
  opacity: 0.5;
213 +
  line-height: 1.4;
214 +
}
215 +
216 +
/* ── Spinner (braille) ────────────────────────────────────────────── */
217 +
218 +
.spinner::after {
219 +
  content: "⠋";
220 +
  display: inline-block;
221 +
  margin-left: 0.4rem;
222 +
  animation: braille-spin 0.8s steps(10) infinite;
223 +
}
224 +
225 +
@keyframes braille-spin {
226 +
  0%   { content: "⠋"; }
227 +
  10%  { content: "⠙"; }
228 +
  20%  { content: "⠹"; }
229 +
  30%  { content: "⠸"; }
230 +
  40%  { content: "⠼"; }
231 +
  50%  { content: "⠴"; }
232 +
  60%  { content: "⠦"; }
233 +
  70%  { content: "⠧"; }
234 +
  80%  { content: "⠇"; }
235 +
  90%  { content: "⠏"; }
236 +
}
237 +
238 +
/* ── Utility ─────────────────────────────────────────────────────── */
239 +
240 +
.hidden {
241 +
  display: none;
242 +
}
243 +
244 +
/* ── About / intro ───────────────────────────────────────────────── */
245 +
246 +
.about {
247 +
  display: flex;
248 +
  flex-direction: column;
249 +
  gap: 0.5rem;
250 +
  font-size: 14px;
251 +
  line-height: 1.25rem;
252 +
}
253 +
254 +
/* ── Builder ─────────────────────────────────────────────────────── */
255 +
256 +
.builder {
257 +
  display: flex;
258 +
  flex-direction: column;
259 +
  gap: 1rem;
260 +
  width: 100%;
261 +
}
262 +
263 +
.discover-row {
264 +
  display: flex;
265 +
  gap: 0.5rem;
266 +
  width: 100%;
267 +
}
268 +
269 +
.discover-row input {
270 +
  flex: 1;
271 +
}
272 +
273 +
.chips {
274 +
  display: flex;
275 +
  flex-wrap: wrap;
276 +
  gap: 0.5rem;
277 +
}
278 +
279 +
.chip {
280 +
  display: inline-flex;
281 +
  align-items: center;
282 +
  gap: 0.4rem;
283 +
  font-size: 12px;
284 +
  padding: 2px 8px;
285 +
  border: 1px solid #ddd;
286 +
  opacity: 0.7;
287 +
}
288 +
289 +
.chip:hover {
290 +
  border-color: #bbb;
291 +
  opacity: 1;
292 +
}
293 +
294 +
.pending-list {
295 +
  list-style: none;
296 +
  display: flex;
297 +
  flex-direction: column;
298 +
  width: 100%;
299 +
}
300 +
301 +
.pending-list li {
302 +
  display: flex;
303 +
  align-items: center;
304 +
  gap: 0.5rem;
305 +
  padding: 0.4rem 0;
306 +
  border-bottom: 1px solid #ddd;
307 +
  min-width: 0;
308 +
}
309 +
310 +
.favicon {
311 +
  width: 16px;
312 +
  height: 16px;
313 +
  flex-shrink: 0;
314 +
}
315 +
316 +
.pending-list li:last-child {
317 +
  border-bottom: none;
318 +
}
319 +
320 +
.pending-label {
321 +
  font-size: 14px;
322 +
  flex-shrink: 0;
323 +
}
324 +
325 +
.pending-url {
326 +
  flex: 1;
327 +
  font-size: 12px;
328 +
  opacity: 0.5;
329 +
  overflow: hidden;
330 +
  white-space: nowrap;
331 +
  text-overflow: ellipsis;
332 +
}
333 +
334 +
/* ── Feeds list ──────────────────────────────────────────────────── */
335 +
336 +
#feeds-container {
337 +
  width: 100%;
338 +
}
339 +
340 +
.feeds-list {
341 +
  width: 100%;
342 +
  display: flex;
343 +
  flex-direction: column;
344 +
  gap: 1.5rem;
345 +
}
346 +
347 +
.feed-item {
348 +
  width: 100%;
349 +
  display: flex;
350 +
  flex-direction: column;
351 +
  gap: 0.5rem;
352 +
  padding: 1rem 0;
353 +
  border-bottom: 1px solid #ddd;
354 +
}
355 +
356 +
.feed-item:last-child {
357 +
  border-bottom: none;
358 +
}
359 +
360 +
.feed-meta {
361 +
  display: flex;
362 +
  justify-content: space-between;
363 +
  align-items: center;
364 +
  font-size: 12px;
365 +
  opacity: 0.5;
366 +
}
367 +
368 +
.feed-title {
369 +
  font-size: 16px;
370 +
  font-weight: 400;
371 +
  line-height: 1.4;
372 +
}
373 +
374 +
.feed-title a {
375 +
  text-decoration: none;
376 +
}
377 +
378 +
.feed-author {
379 +
  font-size: 12px;
380 +
  opacity: 0.5;
381 +
  font-style: italic;
382 +
}
383 +
384 +
#feed-urls {
385 +
  font-size: 12px;
386 +
  opacity: 0.5;
387 +
}
388 +
389 +
#error {
390 +
  padding: 2rem 0;
391 +
}
392 +
393 +
@media (max-width: 480px) {
394 +
  .feed-meta {
395 +
    flex-direction: column;
396 +
    align-items: flex-start;
397 +
    gap: 0.25rem;
398 +
  }
399 +
400 +
  .feed-title {
401 +
    font-size: 14px;
402 +
  }
403 +
}
templates/index.html (added) +60 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#ffffff" />
7 +
    <link rel="stylesheet" href="/static/styles.css" />
8 +
    <title>Feeds</title>
9 +
    <meta name="description" content="Try out RSS feeds" />
10 +
    <meta property="og:url" content="{{.BaseURL}}" />
11 +
    <meta property="og:type" content="website" />
12 +
    <meta property="og:title" content="Feeds" />
13 +
    <meta property="og:description" content="Try out RSS feeds" />
14 +
  </head>
15 +
  <body>
16 +
    <div class="header">
17 +
      <a href="/" class="logo"><h1>FEEDS</h1></a>
18 +
    </div>
19 +
20 +
    {{if .FeedURLs}}
21 +
    <div id="feed-urls">
22 +
      {{range .FeedURLs}}{{.}}<br>{{end}}
23 +
      <a href="/">edit feeds</a>
24 +
    </div>
25 +
26 +
    {{if .Error}}
27 +
    <div id="error" class="error"><p>{{.Error}}</p></div>
28 +
    {{else}}
29 +
    <div id="feeds-container">
30 +
      <div class="feeds-list">
31 +
        {{range .Items}}
32 +
        <article class="feed-item">
33 +
          <div class="feed-meta"><span class="feed-date">{{.FormattedDate}}</span></div>
34 +
          <h3 class="feed-title"><a href="{{.Link}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3>
35 +
          {{if .Author}}<p class="feed-author">{{.Author}}</p>{{end}}
36 +
        </article>
37 +
        {{end}}
38 +
      </div>
39 +
    </div>
40 +
    {{end}}
41 +
42 +
    {{else}}
43 +
    <div class="about">
44 +
      <p>RSS lets you follow websites without algorithms or accounts. Paste a feed URL or a site address below, add a few feeds, then press Go. Your reading list lives entirely in the URL, so you can bookmark or share it.</p>
45 +
    </div>
46 +
47 +
    <div class="builder">
48 +
      <div class="discover-row">
49 +
        <input id="feed-input" type="url" placeholder="https://example.com or feed URL" autocomplete="off" />
50 +
        <button id="add-button" title="Add feed">+</button>
51 +
      </div>
52 +
      <p id="status" class="hint hidden"></p>
53 +
      <div id="examples" class="chips"></div>
54 +
      <ul id="pending" class="pending-list"></ul>
55 +
      <button id="go-button" disabled>Go</button>
56 +
    </div>
57 +
    <script src="/static/app.js"></script>
58 +
    {{end}}
59 +
  </body>
60 +
</html>