Files
photog/generator.go
T
puttaalu 51b082ccf4 Make profile identity, bio and fonts configurable
The profile header, photo page byline and copyright footer previously
hardcoded the author name, handle, bio, and font choices. Move all of
these to CLI flags on the binary and matching options on the NixOS
module so the same build can serve any single-user gallery.

New surface, all with defaults matching the previous hardcoded values:
  --author       / services.photogallery.author
  --handle       / services.photogallery.handle
  --bio          / services.photogallery.bio
  --bio-alt      / services.photogallery.bioAlt   ("" to omit)
  --fonts-url    / services.photogallery.fontsURL
  --serif-family / services.photogallery.serifFamily
  --ml-family    / services.photogallery.mlFamily

Templates read the two font families from a small inline <style> that
overrides the :root variables, so the stylesheet stays fully static and
the font URL is loaded via the configured stylesheet link.

README updated to describe the new options, the medium/full-size photo
detail flow, and the EXIF-based date prefill on the upload page.
2026-07-02 02:30:35 +02:00

429 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/xml"
"html/template"
"image"
imagedraw "image/draw"
"image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
xdraw "golang.org/x/image/draw"
)
const (
thumbSize = 600
mediumMaxSize = 1600
)
// 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"
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
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
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,
}
}
func (g *Generator) initial() string {
for _, r := range g.author {
return strings.ToUpper(string(r))
}
return ""
}
// 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"} {
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.renderIndex(photos); err != nil {
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",
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.
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
}
}
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())
}
// 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})
}
// renderIndex writes public/index.html.
func (g *Generator) renderIndex(photos []Photo) error {
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
"Photos": photos,
"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,
})
}
// 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,
})
}
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
}