chore: add dynamic meta tags
cd1f3aaa
4 file(s) · +80 −13
| 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 { |
| 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 { |
|
| 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 { |
|
| 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"> |
|