319 lines
7.3 KiB
Go
319 lines
7.3 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
|
|||
|
|
|
|||
|
|
// 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"
|
|||
|
|
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", "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",
|
|||
|
|
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/.
|
|||
|
|
func (g *Generator) processImage(p *Photo) error {
|
|||
|
|
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
|||
|
|
if err := copyFile(src, filepath.Join(g.outputDir, p.File)); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
return generateThumb(src, filepath.Join(g.outputDir, p.Thumb), thumbSize)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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/<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,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|