From d4fec5b0ff45bbd5eb04cb512f15fc6bf9f84646 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Mon, 24 Aug 2026 13:59:49 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 15 ++++++- generator.go | 103 ++++++++++++++++++++++++++++++++++++++++--- templates/photo.html | 19 ++++++++ 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 66a57cd..510224b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` - **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`) - **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//`) 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 +### 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 `static/photo-nav.js` handles arrow keys and touch swipe. Both read their diff --git a/generator.go b/generator.go index 37288c3..ceb3c09 100644 --- a/generator.go +++ b/generator.go @@ -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 diff --git a/templates/photo.html b/templates/photo.html index ad1482f..55dde89 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -4,6 +4,25 @@ {{.Photo.Caption}} + + + + + + + + + + {{if .Photo.OGWidth}} + {{end}} + + + + + +