Files
photog/generator.go
T

479 lines
11 KiB
Go
Raw Normal View History

2026-07-02 00:53:17 +02:00
package main
import (
"encoding/xml"
"html/template"
"image"
imagedraw "image/draw"
2026-07-02 00:53:17 +02:00
"image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"sort"
"strconv"
2026-07-02 00:53:17 +02:00
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
xdraw "golang.org/x/image/draw"
)
const (
thumbSize = 600
mediumMaxSize = 1600
pageSize = 60
)
2026-07-02 00:53:17 +02:00
// PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct {
Caption string `toml:"caption"`
Date string `toml:"date"` // YYYY-MM-DD
}
// 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"
2026-07-02 00:53:17 +02:00
Caption string
Date time.Time
}
type Generator struct {
contentDir string
outputDir string
baseURL string
author string
handle string
bio string
bioAlt string
fontsURL string
serifFamily string
mlFamily string
mu sync.Mutex
tmpl *template.Template
2026-07-02 00:53:17 +02:00
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
2026-07-02 00:53:17 +02:00
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
baseURL: baseURL,
author: author,
handle: handle,
bio: bio,
bioAlt: bioAlt,
fontsURL: fontsURL,
serifFamily: serifFamily,
mlFamily: mlFamily,
tmpl: tmpl,
2026-07-02 00:53:17 +02:00
}
}
func (g *Generator) initial() string {
for _, r := range g.author {
return strings.ToUpper(string(r))
}
return ""
}
2026-07-02 00:53:17 +02:00
// Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex.
func (g *Generator) Build() error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} {
2026-07-02 00:53:17 +02:00
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
}
for i := range photos {
if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err)
}
}
if err := g.renderIndexPages(photos); err != nil {
2026-07-02 00:53:17 +02:00
return err
}
for _, p := range photos {
if err := g.renderPhotoPage(p); err != nil {
return err
}
}
if err := g.renderFeed(photos); err != nil {
return err
}
if err := g.copyStatic(); err != nil {
return err
}
log.Printf("built: %d photos", len(photos))
return nil
}
// loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir)
if err != nil {
return nil, err
}
var photos []Photo
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
continue
}
slug := strings.TrimSuffix(name, filepath.Ext(name))
meta := PhotoMeta{
Caption: slug,
Date: time.Now().Format("2006-01-02"),
}
tomlPath := filepath.Join(g.contentDir, slug+".toml")
if _, err := os.Stat(tomlPath); err == nil {
if _, err := toml.DecodeFile(tomlPath, &meta); err != nil {
log.Printf("parsing %s: %v", tomlPath, err)
}
}
date, err := time.Parse("2006-01-02", meta.Date)
if err != nil {
date = time.Now()
}
photos = append(photos, Photo{
Slug: slug,
File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg",
2026-07-02 00:53:17 +02:00
Caption: meta.Caption,
Date: date,
})
}
sort.Slice(photos, func(i, j int) bool {
return photos[i].Date.After(photos[j].Date)
})
return photos, nil
}
// processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// public/medium/. Outputs are skipped when already newer than the source.
2026-07-02 00:53:17 +02:00
func (g *Generator) processImage(p *Photo) error {
src := filepath.Join(g.contentDir, filepath.Base(p.File))
dstImg := filepath.Join(g.outputDir, p.File)
dstThumb := filepath.Join(g.outputDir, p.Thumb)
dstMedium := filepath.Join(g.outputDir, p.Medium)
if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil {
return err
}
2026-07-02 00:53:17 +02:00
}
if !upToDate(src, dstThumb) {
if err := generateThumb(src, dstThumb, thumbSize); err != nil {
return err
}
}
if !upToDate(src, dstMedium) {
if err := generateMedium(src, dstMedium, mediumMaxSize); err != nil {
return err
}
}
return nil
}
// upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool {
si, err := os.Stat(src)
if err != nil {
return false
}
di, err := os.Stat(dst)
if err != nil {
return false
}
return !di.ModTime().Before(si.ModTime())
2026-07-02 00:53:17 +02:00
}
// generateThumb reads src, centre-crops it to a square, scales it to size×size,
// and writes a JPEG to dst.
func generateThumb(src, dst string, size 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
}
// Centre crop to square.
b := img.Bounds()
w, h := b.Dx(), b.Dy()
crop := w
if h < w {
crop = h
}
x0 := (w - crop) / 2
y0 := (h - crop) / 2
cropped := image.NewRGBA(image.Rect(0, 0, crop, crop))
imagedraw.Draw(cropped, cropped.Bounds(), img, image.Point{x0, y0}, imagedraw.Src)
// Scale to thumbSize×thumbSize using Catmull-Rom for quality.
thumb := image.NewRGBA(image.Rect(0, 0, size, size))
xdraw.CatmullRom.Scale(thumb, thumb.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: 85})
}
// generateMedium reads src, resizes it so its longest side is at most maxDim
// while preserving aspect ratio, and writes a JPEG to dst.
func generateMedium(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)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// renderIndexPages writes public/index.html and public/page/N/index.html
// files, chunking photos into pageSize slices for progressive pagination.
func (g *Generator) renderIndexPages(photos []Photo) error {
total := len(photos)
totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
for i := 0; i < totalPages; i++ {
start := i * pageSize
end := start + pageSize
if end > total {
end = total
}
slice := photos[start:end]
dst := filepath.Join(g.outputDir, "index.html")
if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Photos": slice,
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("index.html", dst, data); err != nil {
return err
}
}
return nil
2026-07-02 00:53:17 +02:00
}
// renderPhotoPage writes public/photo/<slug>/index.html.
func (g *Generator) renderPhotoPage(p Photo) error {
dir := filepath.Join(g.outputDir, "photo", p.Slug)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
"Photo": p,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
2026-07-02 00:53:17 +02:00
})
}
func (g *Generator) render(tmpl, dst string, data any) error {
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
return g.tmpl.ExecuteTemplate(f, tmpl, data)
}
// Atom feed ---------------------------------------------------------------
type atomFeed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
Title string `xml:"title"`
Link atomLink `xml:"link"`
Updated string `xml:"updated"`
ID string `xml:"id"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr,omitempty"`
}
type atomEntry struct {
Title string `xml:"title"`
Link atomLink `xml:"link"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Summary string `xml:"summary"`
}
func (g *Generator) renderFeed(photos []Photo) error {
feed := atomFeed{
Xmlns: "http://www.w3.org/2005/Atom",
Title: "Photo Gallery",
Link: atomLink{Href: g.baseURL + "/feed.xml", Rel: "self"},
ID: g.baseURL + "/",
}
if len(photos) > 0 {
feed.Updated = photos[0].Date.Format(time.RFC3339)
}
for _, p := range photos {
url := g.baseURL + "/photo/" + p.Slug + "/"
feed.Entries = append(feed.Entries, atomEntry{
Title: p.Caption,
Link: atomLink{Href: url},
ID: url,
Updated: p.Date.Format(time.RFC3339),
Summary: p.Caption,
})
}
f, err := os.Create(filepath.Join(g.outputDir, "feed.xml"))
if err != nil {
return err
}
defer f.Close()
f.WriteString(xml.Header)
enc := xml.NewEncoder(f)
enc.Indent("", " ")
return enc.Encode(feed)
}
// copyStatic copies embedded static/ assets to public/static/.
func (g *Generator) copyStatic() error {
entries, err := assets.ReadDir("static")
if err != nil {
return err
}
for _, e := range entries {
data, err := assets.ReadFile("static/" + e.Name())
if err != nil {
return err
}
dst := filepath.Join(g.outputDir, "static", e.Name())
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}