Initial commit
This commit is contained in:
+318
@@ -0,0 +1,318 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user