ec42a65c5b
Renders a © 2000–<current-year> Ashik Salahudeen line under both the index and each photo page. The year comes from time.Now().Year() at render time so the range stays current without manual updates.
394 lines
9.0 KiB
Go
394 lines
9.0 KiB
Go
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,
|
||
"Year": time.Now().Year(),
|
||
})
|
||
}
|
||
|
||
// 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(),
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|