package main import ( "encoding/xml" "html/template" "image" imagedraw "image/draw" "image/jpeg" _ "image/png" "io" "log" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/BurntSushi/toml" xdraw "golang.org/x/image/draw" ) const ( thumbSize = 600 mediumMaxSize = 1600 defaultPageSize = 60 ) // 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 } // DayGroup is a run of photos sharing the same calendar day, for the timeline. type DayGroup struct { day string // "2006-01-02", grouping key (unexported: not for templates) Label string // "14 June 2026" Count int Photos []Photo } type Generator struct { contentDir string outputDir string baseURL string author string handle string bio string bioAlt string fontsURL string serifFamily string mlFamily string pageSize int mu sync.Mutex tmpl *template.Template } func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator { tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) if pageSize < 1 { pageSize = defaultPageSize } return &Generator{ contentDir: contentDir, outputDir: outputDir, baseURL: baseURL, author: author, handle: handle, bio: bio, bioAlt: bioAlt, fontsURL: fontsURL, serifFamily: serifFamily, mlFamily: mlFamily, pageSize: pageSize, 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", "grid", "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.renderTimelinePages(photos); err != nil { return err } if err := g.renderGridPages(photos); err != nil { return err } for i := range photos { var newer, older *Photo if i > 0 { newer = &photos[i-1] } if i+1 < len(photos) { older = &photos[i+1] } if err := g.renderPhotoPage(photos[i], newer, older); 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}) } // 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 // timeline is the site root. func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) totalPages := (total + g.pageSize - 1) / g.pageSize if totalPages == 0 { totalPages = 1 } gridDir := filepath.Join(g.outputDir, "grid") for i := 0; i < totalPages; i++ { start := i * g.pageSize end := start + g.pageSize if end > total { end = total } slice := photos[start:end] dst := filepath.Join(gridDir, "index.html") if i > 0 { dir := filepath.Join(gridDir, "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 = "/grid/" default: prev = "/grid/page/" + strconv.Itoa(i) + "/" } if i+1 < totalPages { next = "/grid/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("grid.html", dst, data); err != nil { return err } } return nil } // renderPhotoPage writes public/photo//index.html. func (g *Generator) renderPhotoPage(p Photo, newer, older *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, "Newer": newer, "Older": older, "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, }) } // groupByDay collapses photos (already sorted newest first) into per-day runs, // preserving order. Two photos land in the same group iff their dates share a // calendar day. func groupByDay(photos []Photo) []DayGroup { var groups []DayGroup for _, p := range photos { day := p.Date.Format("2006-01-02") if n := len(groups); n > 0 && groups[n-1].day == day { groups[n-1].Photos = append(groups[n-1].Photos, p) groups[n-1].Count++ continue } groups = append(groups, DayGroup{ day: day, Label: day, // ISO 2006-01-02 Count: 1, Photos: []Photo{p}, }) } return groups } // chunkDayGroups packs day groups into pages, filling each page until it holds // at least target photos, then starting a new one. A single day is never split // across a page boundary, so page sizes vary slightly around target. Always // returns at least one page (empty when there are no photos) so the site root // renders. func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup { var pages [][]DayGroup var cur []DayGroup count := 0 for _, grp := range groups { cur = append(cur, grp) count += grp.Count if count >= target { pages = append(pages, cur) cur, count = nil, 0 } } if len(cur) > 0 { pages = append(pages, cur) } if len(pages) == 0 { pages = [][]DayGroup{nil} } return pages } // renderTimelinePages writes the day-grouped timeline as the site root: // index.html and page/N/index.html. Photos are grouped by day, then packed // into pages of roughly pageSize without splitting a day across pages. func (g *Generator) renderTimelinePages(photos []Photo) error { pages := chunkDayGroups(groupByDay(photos), g.pageSize) totalPages := len(pages) for i, groups := range pages { 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{ "Groups": groups, "Total": len(photos), "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("timeline.html", dst, data); err != nil { return err } } return nil } 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 }