Add Open Graph link previews to photo pages

Shared links now unfurl with an image in WhatsApp, Telegram and anything
else that reads Open Graph, instead of showing the bare URL.

A dedicated og/ rendition is generated per photo: 1200px longest side,
quality laddered down until it fits under ~250 KB. WhatsApp drops the
preview entirely once the image passes ~300 KB, and the medium rendition
can exceed that, so it is not reused. Dimensions for og:image:width and
og:image:height are read back from the encoded file with DecodeConfig, so
they are emitted even when the rendition itself was skipped as up to date.

The og:image and og:url values are absolute via BaseURL; both crawlers
ignore relative image paths. Previews therefore only work against the
deployed base URL, not http://localhost.
This commit is contained in:
2026-08-24 13:59:49 +02:00
parent eb6dba6eb0
commit d4fec5b0ff
3 changed files with 129 additions and 8 deletions
+96 -7
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"encoding/xml"
"html/template"
"image"
@@ -25,8 +26,19 @@ const (
thumbSize = 600
mediumMaxSize = 1600
defaultPageSize = 60
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so
// the medium rendition (which can exceed that) is not reused here.
ogMaxSize = 1200
ogTargetBytes = 250 * 1024
)
// ogQualityLadder is walked from best to worst until the encoded OG image
// fits under ogTargetBytes. The last entry is used as-is even if still over.
var ogQualityLadder = []int{85, 78, 70, 62}
// PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct {
Caption string `toml:"caption"`
@@ -35,12 +47,15 @@ type PhotoMeta struct {
// Photo is the in-memory representation of one image.
type Photo struct {
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg"
Caption string
Date time.Time
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg"
OG string // relative path under public/, e.g. "og/foo.jpg"
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
OGHeight int
Caption string
Date time.Time
}
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
@@ -106,7 +121,7 @@ func (g *Generator) Build() error {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "day", "static"} {
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
@@ -191,6 +206,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg",
OG: "og/" + slug + ".jpg",
Caption: meta.Caption,
Date: date,
})
@@ -210,6 +226,7 @@ func (g *Generator) processImage(p *Photo) error {
dstImg := filepath.Join(g.outputDir, p.File)
dstThumb := filepath.Join(g.outputDir, p.Thumb)
dstMedium := filepath.Join(g.outputDir, p.Medium)
dstOG := filepath.Join(g.outputDir, p.OG)
if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil {
@@ -226,9 +243,36 @@ func (g *Generator) processImage(p *Photo) error {
return err
}
}
if !upToDate(src, dstOG) {
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
return err
}
}
// og:image:width/height need the OG image's real dimensions, and they must
// be known even when the file above was skipped as up to date. DecodeConfig
// reads only the JPEG header, so this is cheap.
if cfg, err := decodeConfigFile(dstOG); err == nil {
p.OGWidth = cfg.Width
p.OGHeight = cfg.Height
} else {
log.Printf("og dimensions %s: %v", p.Slug, err)
}
return nil
}
// decodeConfigFile reads just the image header of path and returns its config
// (dimensions, colour model) without decoding the pixels.
func decodeConfigFile(path string) (image.Config, error) {
f, err := os.Open(path)
if err != nil {
return image.Config{}, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
return cfg, err
}
// upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool {
si, err := os.Stat(src)
@@ -318,6 +362,51 @@ func generateMedium(src, dst string, maxDim int) error {
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// generateOG reads src, resizes it so its longest side is at most maxDim, and
// writes a JPEG small enough for social link previews. It walks ogQualityLadder
// from best to worst, stopping at the first quality whose output is under
// ogTargetBytes, and falls back to the lowest quality if none fits.
func generateOG(src, dst string, maxDim int) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
nw, nh := w, h
if w > maxDim || h > maxDim {
if w >= h {
nw = maxDim
nh = h * maxDim / w
} else {
nh = maxDim
nw = w * maxDim / h
}
}
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil)
var buf bytes.Buffer
for i, q := range ogQualityLadder {
buf.Reset()
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
return err
}
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
break
}
}
return os.WriteFile(dst, buf.Bytes(), 0644)
}
// renderGridPages writes the paginated square-grid view under public/grid/:
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
// slices for progressive pagination. The grid is the secondary view; the