feat: init
a8b6eff8
12 file(s) · +1314 −0
| 1 | + | feeds |
| 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 | + | } |
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
| 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 | + | } |
| 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> |