chore: add dynamic meta tags cd1f3aaa
Steve Simkins · 2026-07-05 11:37 4 file(s) · +80 −13
app.go +7 −4
29 29
}
30 30
31 31
type indexPageData struct {
32 -
	BaseURL  string
33 -
	Items    []templateItem
34 -
	FeedURLs []string
35 -
	Error    string
32 +
	BaseURL         string
33 +
	Items           []templateItem
34 +
	FeedURLs        []string
35 +
	Error           string
36 +
	MetaTitle       string
37 +
	MetaDescription string
38 +
	CanonicalURL    string
36 39
}
37 40
38 41
func (a *App) routes() *http.ServeMux {
feeds.go +6 −2
169 169
	return html.UnescapeString(b.String())
170 170
}
171 171
172 -
func previewURLs(ctx context.Context, urls []string, perFeed int, log *slog.Logger) []FeedPreviewItem {
172 +
func previewURLs(ctx context.Context, urls []string, perFeed int, log *slog.Logger) ([]FeedPreviewItem, map[string]string) {
173 173
	var wg sync.WaitGroup
174 174
	var mu sync.Mutex
175 175
	items := []FeedPreviewItem{}
176 +
	titles := map[string]string{}
176 177
	for _, raw := range urls {
177 178
		feedURL := strings.TrimSpace(raw)
178 179
		if feedURL == "" {
202 203
			}
203 204
			mu.Lock()
204 205
			items = append(items, local...)
206 +
			if feedTitle != "" {
207 +
				titles[feedURL] = feedTitle
208 +
			}
205 209
			mu.Unlock()
206 210
		}()
207 211
	}
216 220
			return 0
217 221
		}
218 222
	})
219 -
	return items
223 +
	return items, titles
220 224
}
221 225
222 226
func discoverFavicon(ctx context.Context, siteURL string) string {
handlers.go +58 −2
2 2
3 3
import (
4 4
	"context"
5 +
	"fmt"
5 6
	"net/http"
6 7
	"net/url"
7 8
	"strings"
16 17
	if query == "" {
17 18
		query = r.URL.Query().Get("urls")
18 19
	}
19 -
	data := indexPageData{BaseURL: a.BaseURL}
20 +
	data := indexPageData{
21 +
		BaseURL:         a.BaseURL,
22 +
		MetaTitle:       "Feeds",
23 +
		MetaDescription: "Experience RSS feeds",
24 +
		CanonicalURL:    a.BaseURL,
25 +
	}
20 26
	if query == "" {
21 27
		render(a.Templates, w, "index.html", data, a.Log)
22 28
		return
31 37
		urls = urls[:maxFeedURLs]
32 38
	}
33 39
	data.FeedURLs = urls
40 +
	data.CanonicalURL = a.BaseURL + r.URL.RequestURI()
34 41
35 42
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
36 43
	defer cancel()
37 -
	for _, item := range previewURLs(ctx, urls, 0, a.Log) {
44 +
	items, titles := previewURLs(ctx, urls, 0, a.Log)
45 +
	for _, item := range items {
38 46
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
39 47
	}
40 48
	if len(data.Items) == 0 {
41 49
		data.Error = "No items could be loaded from these feeds"
42 50
	}
51 +
	data.MetaTitle, data.MetaDescription = feedMeta(urls, titles, len(data.Items))
43 52
	render(a.Templates, w, "index.html", data, a.Log)
53 +
}
54 +
55 +
// feedMeta builds an og:title and og:description from the shared feed URLs,
56 +
// their resolved titles (falling back to the URL host), and the item count.
57 +
func feedMeta(urls []string, titles map[string]string, itemCount int) (title, description string) {
58 +
	names := make([]string, 0, len(urls))
59 +
	for _, u := range urls {
60 +
		name := titles[u]
61 +
		if name == "" {
62 +
			name = hostName(u)
63 +
		}
64 +
		if name != "" {
65 +
			names = append(names, name)
66 +
		}
67 +
	}
68 +
69 +
	switch {
70 +
	case len(names) == 0:
71 +
		title = "Feeds"
72 +
	case len(names) == 1:
73 +
		title = names[0]
74 +
	default:
75 +
		title = fmt.Sprintf("%s +%d more", names[0], len(names)-1)
76 +
	}
77 +
78 +
	feedWord := "feed"
79 +
	if len(urls) != 1 {
80 +
		feedWord = "feeds"
81 +
	}
82 +
	postWord := "post"
83 +
	if itemCount != 1 {
84 +
		postWord = "posts"
85 +
	}
86 +
	description = fmt.Sprintf("%d %s from %d %s", itemCount, postWord, len(urls), feedWord)
87 +
	return title, description
88 +
}
89 +
90 +
// hostName returns the host of a URL with a leading "www." stripped.
91 +
func hostName(raw string) string {
92 +
	if !strings.Contains(raw, "://") {
93 +
		raw = "https://" + raw
94 +
	}
95 +
	u, err := url.Parse(raw)
96 +
	if err != nil {
97 +
		return ""
98 +
	}
99 +
	return strings.TrimPrefix(u.Host, "www.")
44 100
}
45 101
46 102
type resolvedFeed struct {
templates/index.html +9 −5
5 5
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 6
    <meta name="theme-color" content="#ffffff" />
7 7
    <link rel="stylesheet" href="/static/styles.css" />
8 -
    <title>Feeds</title>
9 -
    <meta name="description" content="Experience RSS feeds" />
8 +
    <title>{{.MetaTitle}}</title>
9 +
    <meta name="description" content="{{.MetaDescription}}" />
10 10
    <link rel="icon" type="image/x-icon" href="/static/favicon.ico" />
11 11
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 12
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
14 14
    <link rel="icon" type="image/png" sizes="192x192" href="/static/android-chrome-192x192.png" />
15 15
    <link rel="icon" type="image/png" sizes="512x512" href="/static/android-chrome-512x512.png" />
16 16
    <link rel="manifest" href="/static/site.webmanifest" />
17 -
    <meta property="og:url" content="{{.BaseURL}}" />
17 +
    <meta property="og:url" content="{{.CanonicalURL}}" />
18 18
    <meta property="og:type" content="website" />
19 -
    <meta property="og:title" content="Feeds" />
20 -
    <meta property="og:description" content="Experience RSS feeds" />
19 +
    <meta property="og:title" content="{{.MetaTitle}}" />
20 +
    <meta property="og:description" content="{{.MetaDescription}}" />
21 21
    <meta property="og:image" content="{{.BaseURL}}/static/og.png" />
22 +
    <meta name="twitter:card" content="summary_large_image" />
23 +
    <meta name="twitter:title" content="{{.MetaTitle}}" />
24 +
    <meta name="twitter:description" content="{{.MetaDescription}}" />
25 +
    <meta name="twitter:image" content="{{.BaseURL}}/static/og.png" />
22 26
  </head>
23 27
  <body>
24 28
    <div class="header">