chore: added dynamic og image
63d8dde9
9 file(s) · +330 −20
| 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 |
|
| 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 | + | } |
| 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 |
|
| 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 | ) |
| 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= |
|
| 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, |
|
| 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") |
|
| 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 | + | } |
| 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"> |