856439f3fd
Emit /page/N/ pages alongside index.html and render a pager at the bottom of each page. Ships as a plain link that navigates without JS, and an IntersectionObserver enhancement that fetches the next page and appends tiles for an infinite-scroll feel.
479 lines
11 KiB
Go
479 lines
11 KiB
Go
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
|
||
pageSize = 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
|
||
}
|
||
|
||
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.renderIndexPages(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})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|