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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user