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}} + + + + + +