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
+14 -1
View File
@@ -27,7 +27,7 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image` - **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
- **Templates** — `html/template`, embedded via `embed.FS` - **Templates** — `html/template`, embedded via `embed.FS`
- **Images** — three renditions per photo: original copy, 600×600 centre-cropped square thumbnail, and a 1600px-longest-side "medium" for the detail page. Catmull-Rom resize. Outputs are skipped when newer than the source. - **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source.
- **Feed** — Atom (`encoding/xml`) - **Feed** — Atom (`encoding/xml`)
- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default` - **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default`
@@ -51,6 +51,19 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. - Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Single photo page with sidebar and newer/older navigation - Single photo page with sidebar and newer/older navigation
### Link previews
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
in WhatsApp, Telegram, etc. `generateOG` writes a dedicated `public/og/`
rendition — 1200px longest side, quality laddered down until under ~250 KB,
because WhatsApp drops the preview above ~300 KB and the medium rendition can
exceed that. The `og:image` URL is absolute (`{{.BaseURL}}/...`); both crawlers
ignore relative image paths, and previews only work against the deployed
`-base-url`, never `http://localhost`. `og:image:width`/`height` come from
`DecodeConfig` on the OG file, read even when the file itself was skipped as up
to date. Both platforms cache per URL — after a deploy, re-scrape via
`@webpagebot` (Telegram) or Facebook's sharing debugger (WhatsApp).
### Photo page navigation ### Photo page navigation
`static/photo-nav.js` handles arrow keys and touch swipe. Both read their `static/photo-nav.js` handles arrow keys and touch swipe. Both read their
+96 -7
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/xml" "encoding/xml"
"html/template" "html/template"
"image" "image"
@@ -25,8 +26,19 @@ const (
thumbSize = 600 thumbSize = 600
mediumMaxSize = 1600 mediumMaxSize = 1600
defaultPageSize = 60 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. // PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct { type PhotoMeta struct {
Caption string `toml:"caption"` Caption string `toml:"caption"`
@@ -35,12 +47,15 @@ type PhotoMeta struct {
// Photo is the in-memory representation of one image. // Photo is the in-memory representation of one image.
type Photo struct { type Photo struct {
Slug string Slug string
File string // relative path under public/, e.g. "images/foo.jpg" File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg" Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg" Medium string // relative path under public/, e.g. "medium/foo.jpg"
Caption string OG string // relative path under public/, e.g. "og/foo.jpg"
Date time.Time 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. // 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 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 { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err return err
} }
@@ -191,6 +206,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
File: "images/" + name, File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg", Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg", Medium: "medium/" + slug + ".jpg",
OG: "og/" + slug + ".jpg",
Caption: meta.Caption, Caption: meta.Caption,
Date: date, Date: date,
}) })
@@ -210,6 +226,7 @@ func (g *Generator) processImage(p *Photo) error {
dstImg := filepath.Join(g.outputDir, p.File) dstImg := filepath.Join(g.outputDir, p.File)
dstThumb := filepath.Join(g.outputDir, p.Thumb) dstThumb := filepath.Join(g.outputDir, p.Thumb)
dstMedium := filepath.Join(g.outputDir, p.Medium) dstMedium := filepath.Join(g.outputDir, p.Medium)
dstOG := filepath.Join(g.outputDir, p.OG)
if !upToDate(src, dstImg) { if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil { if err := copyFile(src, dstImg); err != nil {
@@ -226,9 +243,36 @@ func (g *Generator) processImage(p *Photo) error {
return err 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 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. // upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool { func upToDate(src, dst string) bool {
si, err := os.Stat(src) 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}) 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/: // renderGridPages writes the paginated square-grid view under public/grid/:
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize // grid/index.html and grid/page/N/index.html, chunking photos into pageSize
// slices for progressive pagination. The grid is the secondary view; the // slices for progressive pagination. The grid is the secondary view; the
+19
View File
@@ -4,6 +4,25 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Photo.Caption}}</title> <title>{{.Photo.Caption}}</title>
<!-- Open Graph + Twitter card: the preview shown when the link is shared to
WhatsApp, Telegram, etc. URLs are absolute because both crawlers ignore
relative image paths. Served from public/og/, kept under ~250 KB. -->
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{.Author}}">
<meta property="og:title" content="{{.Photo.Caption}}">
<meta property="og:description" content="{{.Author}} — {{.Photo.Date.Format "January 2, 2006"}}">
<meta property="og:url" content="{{.BaseURL}}/photo/{{.Photo.Slug}}/">
<meta property="og:image" content="{{.BaseURL}}/{{.Photo.OG}}">
<meta property="og:image:type" content="image/jpeg">
{{if .Photo.OGWidth}}<meta property="og:image:width" content="{{.Photo.OGWidth}}">
<meta property="og:image:height" content="{{.Photo.OGHeight}}">{{end}}
<meta property="og:image:alt" content="{{.Photo.Caption}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.Photo.Caption}}">
<meta name="twitter:description" content="{{.Author}} — {{.Photo.Date.Format "January 2, 2006"}}">
<meta name="twitter:image" content="{{.BaseURL}}/{{.Photo.OG}}">
<link rel="stylesheet" href="{{.FontsURL}}"> <link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style> <style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>