chore: added dynamic og image 63d8dde9
Steve Simkins · 2026-07-05 13:11 9 file(s) · +330 −20
app.go +5 −0
19 19
	Log       *slog.Logger
20 20
	Templates *template.Template
21 21
	BaseURL   string
22 +
	Cache     *feedCache
23 +
	Images    *imageCache
24 +
	renderSem chan struct{}
22 25
}
23 26
24 27
type templateItem struct {
36 39
	MetaTitle       string
37 40
	MetaDescription string
38 41
	CanonicalURL    string
42 +
	OGImage         string
39 43
}
40 44
41 45
func (a *App) routes() *http.ServeMux {
42 46
	mux := http.NewServeMux()
43 47
	mux.HandleFunc("GET /", a.indexHandler)
48 +
	mux.HandleFunc("GET /og.png", a.ogImageHandler)
44 49
	mux.HandleFunc("GET /api/resolve", a.resolveHandler)
45 50
	mux.HandleFunc("GET /static/", embeddedHandler(appFS, "static"))
46 51
	return mux
cache.go (added) +112 −0
1 +
package main
2 +
3 +
import (
4 +
	"container/list"
5 +
	"context"
6 +
	"sync"
7 +
	"time"
8 +
)
9 +
10 +
// feedCache is a small TTL cache of fetched feed results, keyed by feed URL.
11 +
// It keeps crawler-triggered requests (page + OG image) fast after the first
12 +
// fetch warms the entry.
13 +
type feedCache struct {
14 +
	mu      sync.Mutex
15 +
	ttl     time.Duration
16 +
	entries map[string]feedCacheEntry
17 +
}
18 +
19 +
type feedCacheEntry struct {
20 +
	res     *FetchResult
21 +
	expires time.Time
22 +
}
23 +
24 +
func newFeedCache(ttl time.Duration) *feedCache {
25 +
	return &feedCache{ttl: ttl, entries: map[string]feedCacheEntry{}}
26 +
}
27 +
28 +
// fetch returns a cached result when fresh, otherwise fetches and stores it.
29 +
// Concurrent misses for the same URL may each fetch; the last write wins.
30 +
func (c *feedCache) fetch(ctx context.Context, feedURL string) (*FetchResult, error) {
31 +
	c.mu.Lock()
32 +
	if e, ok := c.entries[feedURL]; ok && time.Now().Before(e.expires) {
33 +
		c.mu.Unlock()
34 +
		return e.res, nil
35 +
	}
36 +
	c.mu.Unlock()
37 +
38 +
	res, err := fetchFeed(ctx, feedURL, "", "")
39 +
	if err != nil {
40 +
		return nil, err
41 +
	}
42 +
43 +
	c.mu.Lock()
44 +
	c.entries[feedURL] = feedCacheEntry{res: res, expires: time.Now().Add(c.ttl)}
45 +
	c.mu.Unlock()
46 +
	return res, nil
47 +
}
48 +
49 +
// imageCache is an LRU + TTL cache of rendered PNG bytes, keyed by the shared
50 +
// URL set. It bounds memory (max entries) and lets repeated shares of the same
51 +
// link skip the CPU/allocation cost of re-rendering.
52 +
type imageCache struct {
53 +
	mu      sync.Mutex
54 +
	ttl     time.Duration
55 +
	max     int
56 +
	ll      *list.List // front = most recently used
57 +
	entries map[string]*list.Element
58 +
}
59 +
60 +
type imageEntry struct {
61 +
	key     string
62 +
	png     []byte
63 +
	expires time.Time
64 +
}
65 +
66 +
func newImageCache(ttl time.Duration, max int) *imageCache {
67 +
	return &imageCache{
68 +
		ttl:     ttl,
69 +
		max:     max,
70 +
		ll:      list.New(),
71 +
		entries: map[string]*list.Element{},
72 +
	}
73 +
}
74 +
75 +
func (c *imageCache) get(key string) ([]byte, bool) {
76 +
	c.mu.Lock()
77 +
	defer c.mu.Unlock()
78 +
	el, ok := c.entries[key]
79 +
	if !ok {
80 +
		return nil, false
81 +
	}
82 +
	ent := el.Value.(*imageEntry)
83 +
	if time.Now().After(ent.expires) {
84 +
		c.ll.Remove(el)
85 +
		delete(c.entries, key)
86 +
		return nil, false
87 +
	}
88 +
	c.ll.MoveToFront(el)
89 +
	return ent.png, true
90 +
}
91 +
92 +
func (c *imageCache) set(key string, png []byte) {
93 +
	c.mu.Lock()
94 +
	defer c.mu.Unlock()
95 +
	if el, ok := c.entries[key]; ok {
96 +
		ent := el.Value.(*imageEntry)
97 +
		ent.png = png
98 +
		ent.expires = time.Now().Add(c.ttl)
99 +
		c.ll.MoveToFront(el)
100 +
		return
101 +
	}
102 +
	el := c.ll.PushFront(&imageEntry{key: key, png: png, expires: time.Now().Add(c.ttl)})
103 +
	c.entries[key] = el
104 +
	for c.ll.Len() > c.max {
105 +
		oldest := c.ll.Back()
106 +
		if oldest == nil {
107 +
			break
108 +
		}
109 +
		c.ll.Remove(oldest)
110 +
		delete(c.entries, oldest.Value.(*imageEntry).key)
111 +
	}
112 +
}
feeds.go +2 −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, map[string]string) {
172 +
func previewURLs(ctx context.Context, urls []string, perFeed int, cache *feedCache, log *slog.Logger) ([]FeedPreviewItem, map[string]string) {
173 173
	var wg sync.WaitGroup
174 174
	var mu sync.Mutex
175 175
	items := []FeedPreviewItem{}
182 182
		wg.Add(1)
183 183
		go func() {
184 184
			defer wg.Done()
185 -
			res, err := fetchFeed(ctx, feedURL, "", "")
185 +
			res, err := cache.fetch(ctx, feedURL)
186 186
			if err != nil {
187 187
				log.Warn("preview fetch failed", "url", feedURL, "err", err)
188 188
				return
go.mod +4 −1
3 3
go 1.25.0
4 4
5 5
require (
6 +
	github.com/fogleman/gg v1.3.0
6 7
	github.com/mmcdole/gofeed v1.3.0
7 8
	golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2
9 +
	golang.org/x/image v0.43.0
8 10
	golang.org/x/net v0.41.0
9 11
)
10 12
11 13
require (
12 14
	github.com/PuerkitoBio/goquery v1.8.0 // indirect
13 15
	github.com/andybalholm/cascadia v1.3.1 // indirect
16 +
	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
14 17
	github.com/json-iterator/go v1.1.12 // indirect
15 18
	github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
16 19
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
17 20
	github.com/modern-go/reflect2 v1.0.2 // indirect
18 -
	golang.org/x/text v0.26.0 // indirect
21 +
	golang.org/x/text v0.38.0 // indirect
19 22
)
go.sum +8 −2
5 5
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 6
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 7
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 +
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
9 +
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
10 +
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
11 +
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
8 12
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
9 13
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
10 14
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
25 29
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
26 30
golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2 h1:7Y5FZkvYs5XMyG0VS/pONmKIgD9+9eqcm1DGar541SA=
27 31
golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
32 +
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
33 +
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
28 34
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
29 35
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
30 36
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
32 38
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
33 39
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
34 40
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=
41 +
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
42 +
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
37 43
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
38 44
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
39 45
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
handlers.go +18 −13
13 13
const maxFeedURLs = 20
14 14
15 15
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
16 -
	query := r.URL.Query().Get("url")
17 -
	if query == "" {
18 -
		query = r.URL.Query().Get("urls")
19 -
	}
20 16
	data := indexPageData{
21 17
		BaseURL:         a.BaseURL,
22 18
		MetaTitle:       "Feeds",
23 19
		MetaDescription: "Experience RSS feeds",
24 20
		CanonicalURL:    a.BaseURL,
25 -
	}
26 -
	if query == "" {
27 -
		render(a.Templates, w, "index.html", data, a.Log)
28 -
		return
21 +
		OGImage:         a.BaseURL + "/static/og.png",
29 22
	}
30 23
31 -
	urls := splitAndTrim(query)
24 +
	urls := feedURLsFromRequest(r)
32 25
	if len(urls) == 0 {
33 26
		render(a.Templates, w, "index.html", data, a.Log)
34 27
		return
35 28
	}
36 -
	if len(urls) > maxFeedURLs {
37 -
		urls = urls[:maxFeedURLs]
38 -
	}
39 29
	data.FeedURLs = urls
40 30
	data.CanonicalURL = a.BaseURL + r.URL.RequestURI()
31 +
	data.OGImage = a.BaseURL + "/og.png?" + r.URL.RawQuery
41 32
42 33
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
43 34
	defer cancel()
44 -
	items, titles := previewURLs(ctx, urls, 0, a.Log)
35 +
	items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log)
45 36
	for _, item := range items {
46 37
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
47 38
	}
50 41
	}
51 42
	data.MetaTitle, data.MetaDescription = feedMeta(urls, titles, len(data.Items))
52 43
	render(a.Templates, w, "index.html", data, a.Log)
44 +
}
45 +
46 +
// feedURLsFromRequest extracts and normalizes the feed URLs from the "url" or
47 +
// "urls" query param, capped at maxFeedURLs.
48 +
func feedURLsFromRequest(r *http.Request) []string {
49 +
	query := r.URL.Query().Get("url")
50 +
	if query == "" {
51 +
		query = r.URL.Query().Get("urls")
52 +
	}
53 +
	urls := splitAndTrim(query)
54 +
	if len(urls) > maxFeedURLs {
55 +
		urls = urls[:maxFeedURLs]
56 +
	}
57 +
	return urls
53 58
}
54 59
55 60
// feedMeta builds an og:title and og:description from the shared feed URLs,
main.go +6 −0
6 6
	"log/slog"
7 7
	"net/http"
8 8
	"os"
9 +
	"runtime"
10 +
	"time"
9 11
10 12
	_ "golang.org/x/crypto/x509roots/fallback"
11 13
)
16 18
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 19
18 20
	tmpl := template.Must(template.New("").ParseFS(appFS, "templates/*.html"))
21 +
	renderSlots := runtime.NumCPU()
19 22
	app := &App{
20 23
		Log:       logger,
21 24
		Templates: tmpl,
22 25
		BaseURL:   getenv("BASE_URL", "http://localhost:3000"),
26 +
		Cache:     newFeedCache(5 * time.Minute),
27 +
		Images:    newImageCache(10*time.Minute, 512),
28 +
		renderSem: make(chan struct{}, renderSlots),
23 29
	}
24 30
25 31
	addr := getenv("HOST", "0.0.0.0") + ":" + getenv("PORT", "3000")
og.go (added) +173 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"context"
6 +
	"net/http"
7 +
	"sort"
8 +
	"strings"
9 +
	"sync"
10 +
	"time"
11 +
	"unicode/utf8"
12 +
13 +
	"github.com/fogleman/gg"
14 +
	"golang.org/x/image/font"
15 +
	"golang.org/x/image/font/opentype"
16 +
)
17 +
18 +
const (
19 +
	ogWidth  = 1200
20 +
	ogHeight = 630
21 +
	ogMargin = 80.0
22 +
)
23 +
24 +
var (
25 +
	fontOnce              sync.Once
26 +
	fontRegular, fontBold *opentype.Font
27 +
	fontErr               error
28 +
)
29 +
30 +
// loadFonts parses the embedded CommitMono OTFs once. They use CFF outlines,
31 +
// which the freetype loader gg ships with can't read, so we parse them with
32 +
// x/image/font/opentype (sfnt) instead.
33 +
func loadFonts() {
34 +
	fontOnce.Do(func() {
35 +
		rb, err := appFS.ReadFile("static/fonts/CommitMono-400-Regular.otf")
36 +
		if err != nil {
37 +
			fontErr = err
38 +
			return
39 +
		}
40 +
		bb, err := appFS.ReadFile("static/fonts/CommitMono-700-Regular.otf")
41 +
		if err != nil {
42 +
			fontErr = err
43 +
			return
44 +
		}
45 +
		if fontRegular, err = opentype.Parse(rb); err != nil {
46 +
			fontErr = err
47 +
			return
48 +
		}
49 +
		if fontBold, err = opentype.Parse(bb); err != nil {
50 +
			fontErr = err
51 +
			return
52 +
		}
53 +
	})
54 +
}
55 +
56 +
// newFace builds a font.Face at the given size. Faces are created per call
57 +
// because opentype faces are not safe for concurrent use.
58 +
func newFace(bold bool, size float64) (font.Face, error) {
59 +
	loadFonts()
60 +
	if fontErr != nil {
61 +
		return nil, fontErr
62 +
	}
63 +
	src := fontRegular
64 +
	if bold {
65 +
		src = fontBold
66 +
	}
67 +
	return opentype.NewFace(src, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
68 +
}
69 +
70 +
func (a *App) ogImageHandler(w http.ResponseWriter, r *http.Request) {
71 +
	urls := feedURLsFromRequest(r)
72 +
	key := ogCacheKey(urls)
73 +
74 +
	// Serve a previously rendered PNG without touching the render path.
75 +
	if png, ok := a.Images.get(key); ok {
76 +
		writePNG(w, png)
77 +
		return
78 +
	}
79 +
80 +
	ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
81 +
	defer cancel()
82 +
83 +
	title, desc := "Feeds", "Experience RSS feeds"
84 +
	if len(urls) > 0 {
85 +
		items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log)
86 +
		title, desc = feedMeta(urls, titles, len(items))
87 +
	}
88 +
89 +
	// Bound concurrent renders to cap CPU and peak memory (each render
90 +
	// allocates a ~3MB bitmap). Overloaded requests bail rather than pile up.
91 +
	select {
92 +
	case a.renderSem <- struct{}{}:
93 +
		defer func() { <-a.renderSem }()
94 +
	case <-ctx.Done():
95 +
		http.Error(w, "render busy", http.StatusServiceUnavailable)
96 +
		return
97 +
	}
98 +
99 +
	png, err := renderOGImage(title, desc)
100 +
	if err != nil {
101 +
		a.Log.Error("og render failed", "err", err)
102 +
		http.Error(w, "og render error", http.StatusInternalServerError)
103 +
		return
104 +
	}
105 +
	a.Images.set(key, png)
106 +
	writePNG(w, png)
107 +
}
108 +
109 +
// ogCacheKey normalizes a URL set into a stable cache key (order-independent).
110 +
func ogCacheKey(urls []string) string {
111 +
	sorted := append([]string(nil), urls...)
112 +
	sort.Strings(sorted)
113 +
	return strings.Join(sorted, "\n")
114 +
}
115 +
116 +
func writePNG(w http.ResponseWriter, png []byte) {
117 +
	w.Header().Set("Content-Type", "image/png")
118 +
	w.Header().Set("Cache-Control", "public, max-age=300")
119 +
	_, _ = w.Write(png)
120 +
}
121 +
122 +
// renderOGImage draws a 1200x630 social card with the feed title and a subtitle.
123 +
func renderOGImage(title, desc string) ([]byte, error) {
124 +
	dc := gg.NewContext(ogWidth, ogHeight)
125 +
	dc.SetHexColor("#ffffff")
126 +
	dc.Clear()
127 +
128 +
	// Top accent bar.
129 +
	dc.SetHexColor("#1a1a1a")
130 +
	dc.DrawRectangle(0, 0, ogWidth, 12)
131 +
	dc.Fill()
132 +
133 +
	// "FEEDS" wordmark.
134 +
	wordmark, err := newFace(true, 34)
135 +
	if err != nil {
136 +
		return nil, err
137 +
	}
138 +
	dc.SetFontFace(wordmark)
139 +
	dc.SetHexColor("#1a1a1a")
140 +
	dc.DrawString("FEEDS", ogMargin, 120)
141 +
142 +
	// Title, wrapped, truncated so it can't run into the subtitle.
143 +
	titleFace, err := newFace(true, 68)
144 +
	if err != nil {
145 +
		return nil, err
146 +
	}
147 +
	dc.SetFontFace(titleFace)
148 +
	dc.SetHexColor("#1a1a1a")
149 +
	dc.DrawStringWrapped(truncate(title, 90), ogMargin, 200, 0, 0, ogWidth-2*ogMargin, 1.35, gg.AlignLeft)
150 +
151 +
	// Subtitle pinned near the bottom.
152 +
	subFace, err := newFace(false, 34)
153 +
	if err != nil {
154 +
		return nil, err
155 +
	}
156 +
	dc.SetFontFace(subFace)
157 +
	dc.SetHexColor("#6b6b6b")
158 +
	dc.DrawString(desc, ogMargin, ogHeight-ogMargin)
159 +
160 +
	var buf bytes.Buffer
161 +
	if err := dc.EncodePNG(&buf); err != nil {
162 +
		return nil, err
163 +
	}
164 +
	return buf.Bytes(), nil
165 +
}
166 +
167 +
func truncate(s string, maxRunes int) string {
168 +
	if utf8.RuneCountInString(s) <= maxRunes {
169 +
		return s
170 +
	}
171 +
	runes := []rune(s)
172 +
	return string(runes[:maxRunes]) + "…"
173 +
}
templates/index.html +2 −2
18 18
    <meta property="og:type" content="website" />
19 19
    <meta property="og:title" content="{{.MetaTitle}}" />
20 20
    <meta property="og:description" content="{{.MetaDescription}}" />
21 -
    <meta property="og:image" content="{{.BaseURL}}/static/og.png" />
21 +
    <meta property="og:image" content="{{.OGImage}}" />
22 22
    <meta name="twitter:card" content="summary_large_image" />
23 23
    <meta name="twitter:title" content="{{.MetaTitle}}" />
24 24
    <meta name="twitter:description" content="{{.MetaDescription}}" />
25 -
    <meta name="twitter:image" content="{{.BaseURL}}/static/og.png" />
25 +
    <meta name="twitter:image" content="{{.OGImage}}" />
26 26
  </head>
27 27
  <body>
28 28
    <div class="header">