package main import ( "encoding/xml" "html/template" imagedraw "image/draw" "image" "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 mu sync.Mutex tmpl *template.Template } func NewGenerator(contentDir, outputDir, baseURL string) *Generator { tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) return &Generator{ contentDir: contentDir, outputDir: outputDir, baseURL: baseURL, tmpl: tmpl, } } // 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, }) } // renderPhotoPage writes public/photo//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, }) } 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 }