2026-07-02 00:53:17 +02:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-24 13:59:49 +02:00
|
|
|
|
"bytes"
|
2026-07-02 00:53:17 +02:00
|
|
|
|
"encoding/xml"
|
|
|
|
|
|
"html/template"
|
|
|
|
|
|
"image"
|
2026-07-02 02:30:35 +02:00
|
|
|
|
imagedraw "image/draw"
|
2026-07-02 00:53:17 +02:00
|
|
|
|
"image/jpeg"
|
|
|
|
|
|
_ "image/png"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"sort"
|
2026-07-02 22:20:28 +02:00
|
|
|
|
"strconv"
|
2026-07-02 00:53:17 +02:00
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/BurntSushi/toml"
|
|
|
|
|
|
xdraw "golang.org/x/image/draw"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-02 01:53:23 +02:00
|
|
|
|
const (
|
2026-08-20 00:11:08 +02:00
|
|
|
|
thumbSize = 600
|
|
|
|
|
|
mediumMaxSize = 1600
|
|
|
|
|
|
defaultPageSize = 60
|
2026-08-24 13:59:49 +02:00
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
// The two paginated views. The one chosen as -default-view is published
|
|
|
|
|
|
// at the site root, the other under /<name>/.
|
|
|
|
|
|
viewTimeline = "timeline"
|
|
|
|
|
|
viewGrid = "grid"
|
|
|
|
|
|
|
2026-08-24 13:59:49 +02:00
|
|
|
|
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
|
|
|
|
|
|
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
|
|
|
|
|
|
// reported to drop the preview entirely once the image passes ~300 KB, so
|
|
|
|
|
|
// the medium rendition (which can exceed that) is not reused here.
|
|
|
|
|
|
ogMaxSize = 1200
|
|
|
|
|
|
ogTargetBytes = 250 * 1024
|
2026-07-02 01:53:23 +02:00
|
|
|
|
)
|
2026-07-02 00:53:17 +02:00
|
|
|
|
|
2026-08-24 13:59:49 +02:00
|
|
|
|
// ogQualityLadder is walked from best to worst until the encoded OG image
|
|
|
|
|
|
// fits under ogTargetBytes. The last entry is used as-is even if still over.
|
|
|
|
|
|
var ogQualityLadder = []int{85, 78, 70, 62}
|
|
|
|
|
|
|
2026-07-02 00:53:17 +02:00
|
|
|
|
// 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 {
|
2026-08-24 13:59:49 +02:00
|
|
|
|
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"
|
|
|
|
|
|
OG string // relative path under public/, e.g. "og/foo.jpg"
|
|
|
|
|
|
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
|
|
|
|
|
|
OGHeight int
|
|
|
|
|
|
Caption string
|
|
|
|
|
|
Date time.Time
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 23:46:28 +02:00
|
|
|
|
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
|
|
|
|
|
type DayGroup struct {
|
|
|
|
|
|
day string // "2006-01-02", grouping key (unexported: not for templates)
|
|
|
|
|
|
Label string // "14 June 2026"
|
|
|
|
|
|
Count int
|
|
|
|
|
|
Photos []Photo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:53:17 +02:00
|
|
|
|
type Generator struct {
|
2026-07-02 02:30:35 +02:00
|
|
|
|
contentDir string
|
|
|
|
|
|
outputDir string
|
|
|
|
|
|
baseURL string
|
|
|
|
|
|
author string
|
|
|
|
|
|
handle string
|
|
|
|
|
|
bio string
|
|
|
|
|
|
bioAlt string
|
|
|
|
|
|
fontsURL string
|
|
|
|
|
|
serifFamily string
|
|
|
|
|
|
mlFamily string
|
2026-08-20 00:11:08 +02:00
|
|
|
|
pageSize int
|
2026-09-28 07:10:29 +02:00
|
|
|
|
defaultView string
|
2026-07-02 02:30:35 +02:00
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
tmpl *template.Template
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator {
|
2026-07-02 00:53:17 +02:00
|
|
|
|
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
2026-08-20 00:11:08 +02:00
|
|
|
|
if pageSize < 1 {
|
|
|
|
|
|
pageSize = defaultPageSize
|
|
|
|
|
|
}
|
2026-09-28 07:10:29 +02:00
|
|
|
|
if defaultView != viewGrid {
|
|
|
|
|
|
defaultView = viewTimeline
|
|
|
|
|
|
}
|
2026-07-02 00:53:17 +02:00
|
|
|
|
return &Generator{
|
2026-07-02 02:30:35 +02:00
|
|
|
|
contentDir: contentDir,
|
|
|
|
|
|
outputDir: outputDir,
|
|
|
|
|
|
baseURL: baseURL,
|
|
|
|
|
|
author: author,
|
|
|
|
|
|
handle: handle,
|
|
|
|
|
|
bio: bio,
|
|
|
|
|
|
bioAlt: bioAlt,
|
|
|
|
|
|
fontsURL: fontsURL,
|
|
|
|
|
|
serifFamily: serifFamily,
|
|
|
|
|
|
mlFamily: mlFamily,
|
2026-08-20 00:11:08 +02:00
|
|
|
|
pageSize: pageSize,
|
2026-09-28 07:10:29 +02:00
|
|
|
|
defaultView: defaultView,
|
2026-07-02 02:30:35 +02:00
|
|
|
|
tmpl: tmpl,
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 02:30:35 +02:00
|
|
|
|
func (g *Generator) initial() string {
|
|
|
|
|
|
for _, r := range g.author {
|
|
|
|
|
|
return strings.ToUpper(string(r))
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
// viewBase returns the URL prefix a view is published under: "" for the
|
|
|
|
|
|
// default view, which owns the site root, and "/<view>" for the other one.
|
|
|
|
|
|
func (g *Generator) viewBase(view string) string {
|
|
|
|
|
|
if view == g.defaultView {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return "/" + view
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// pageURL returns the URL of the 1-based page n of a view rooted at base.
|
|
|
|
|
|
func pageURL(base string, n int) string {
|
|
|
|
|
|
if n == 1 {
|
|
|
|
|
|
return base + "/"
|
|
|
|
|
|
}
|
|
|
|
|
|
return base + "/page/" + strconv.Itoa(n) + "/"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:53:17 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} {
|
2026-07-02 00:53:17 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
// The default view lives at the site root, so a directory named after it
|
|
|
|
|
|
// can only be left over from a run with the other -default-view. Drop it,
|
|
|
|
|
|
// or the old pages would stay reachable and go stale.
|
|
|
|
|
|
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 00:11:08 +02:00
|
|
|
|
if err := g.renderTimelinePages(photos); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := g.renderGridPages(photos); err != nil {
|
2026-07-02 00:53:17 +02:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-20 00:13:49 +02:00
|
|
|
|
if err := g.renderDayPages(photos); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-07-03 23:03:54 +02:00
|
|
|
|
for i := range photos {
|
|
|
|
|
|
var newer, older *Photo
|
|
|
|
|
|
if i > 0 {
|
|
|
|
|
|
newer = &photos[i-1]
|
|
|
|
|
|
}
|
|
|
|
|
|
if i+1 < len(photos) {
|
|
|
|
|
|
older = &photos[i+1]
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := g.renderPhotoPage(photos[i], newer, older); err != nil {
|
2026-07-02 00:53:17 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 08:22:18 +02:00
|
|
|
|
// Delete removes a photo for good: its image and sidecar in the content
|
|
|
|
|
|
// directory, then everything generated from them. Pages that list the photo
|
|
|
|
|
|
// are put right by the next Build. It returns os.ErrNotExist when no photo
|
|
|
|
|
|
// has that slug.
|
|
|
|
|
|
func (g *Generator) Delete(slug string) error {
|
|
|
|
|
|
g.mu.Lock()
|
|
|
|
|
|
defer g.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
photos, err := g.loadPhotos()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
found := false
|
|
|
|
|
|
for _, p := range photos {
|
|
|
|
|
|
if p.Slug != slug {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
found = true
|
|
|
|
|
|
// The source goes first: if it cannot be removed, the photo stays
|
|
|
|
|
|
// fully intact rather than half deleted.
|
|
|
|
|
|
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
|
|
|
|
|
|
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
|
|
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
return os.ErrNotExist
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// slug is safe to join from here on: it matched a directory entry.
|
|
|
|
|
|
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:53:17 +02:00
|
|
|
|
// 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",
|
2026-07-02 01:53:23 +02:00
|
|
|
|
Medium: "medium/" + slug + ".jpg",
|
2026-08-24 13:59:49 +02:00
|
|
|
|
OG: "og/" + slug + ".jpg",
|
2026-07-02 00:53:17 +02:00
|
|
|
|
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
|
2026-07-02 01:53:23 +02:00
|
|
|
|
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
|
|
|
|
|
|
// public/medium/. Outputs are skipped when already newer than the source.
|
2026-07-02 00:53:17 +02:00
|
|
|
|
func (g *Generator) processImage(p *Photo) error {
|
2026-07-02 02:30:35 +02:00
|
|
|
|
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
|
|
|
|
|
dstImg := filepath.Join(g.outputDir, p.File)
|
|
|
|
|
|
dstThumb := filepath.Join(g.outputDir, p.Thumb)
|
2026-07-02 01:53:23 +02:00
|
|
|
|
dstMedium := filepath.Join(g.outputDir, p.Medium)
|
2026-08-24 13:59:49 +02:00
|
|
|
|
dstOG := filepath.Join(g.outputDir, p.OG)
|
2026-07-02 01:53:23 +02:00
|
|
|
|
|
|
|
|
|
|
if !upToDate(src, dstImg) {
|
|
|
|
|
|
if err := copyFile(src, dstImg); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
2026-07-02 01:53:23 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-24 13:59:49 +02:00
|
|
|
|
if !upToDate(src, dstOG) {
|
|
|
|
|
|
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// og:image:width/height need the OG image's real dimensions, and they must
|
|
|
|
|
|
// be known even when the file above was skipped as up to date. DecodeConfig
|
|
|
|
|
|
// reads only the JPEG header, so this is cheap.
|
|
|
|
|
|
if cfg, err := decodeConfigFile(dstOG); err == nil {
|
|
|
|
|
|
p.OGWidth = cfg.Width
|
|
|
|
|
|
p.OGHeight = cfg.Height
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("og dimensions %s: %v", p.Slug, err)
|
|
|
|
|
|
}
|
2026-07-02 01:53:23 +02:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 13:59:49 +02:00
|
|
|
|
// decodeConfigFile reads just the image header of path and returns its config
|
|
|
|
|
|
// (dimensions, colour model) without decoding the pixels.
|
|
|
|
|
|
func decodeConfigFile(path string) (image.Config, error) {
|
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return image.Config{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
cfg, _, err := image.DecodeConfig(f)
|
|
|
|
|
|
return cfg, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 01:53:23 +02:00
|
|
|
|
// 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())
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 01:53:23 +02:00
|
|
|
|
// 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})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 13:59:49 +02:00
|
|
|
|
// generateOG reads src, resizes it so its longest side is at most maxDim, and
|
|
|
|
|
|
// writes a JPEG small enough for social link previews. It walks ogQualityLadder
|
|
|
|
|
|
// from best to worst, stopping at the first quality whose output is under
|
|
|
|
|
|
// ogTargetBytes, and falls back to the lowest quality if none fits.
|
|
|
|
|
|
func generateOG(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)
|
|
|
|
|
|
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
for i, q := range ogQualityLadder {
|
|
|
|
|
|
buf.Reset()
|
|
|
|
|
|
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return os.WriteFile(dst, buf.Bytes(), 0644)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 08:22:18 +02:00
|
|
|
|
// renderGridPages writes the paginated square-grid view: index.html and
|
|
|
|
|
|
// page/N/index.html under the grid's base (the site root when the grid is the
|
|
|
|
|
|
// default view, /grid/ otherwise), chunking photos into pageSize slices for
|
|
|
|
|
|
// progressive pagination.
|
2026-08-20 00:11:08 +02:00
|
|
|
|
func (g *Generator) renderGridPages(photos []Photo) error {
|
2026-07-02 22:20:28 +02:00
|
|
|
|
total := len(photos)
|
2026-08-20 00:11:08 +02:00
|
|
|
|
totalPages := (total + g.pageSize - 1) / g.pageSize
|
2026-07-02 22:20:28 +02:00
|
|
|
|
if totalPages == 0 {
|
|
|
|
|
|
totalPages = 1
|
|
|
|
|
|
}
|
2026-09-28 07:10:29 +02:00
|
|
|
|
base := g.viewBase(viewGrid)
|
2026-07-02 22:20:28 +02:00
|
|
|
|
|
|
|
|
|
|
for i := 0; i < totalPages; i++ {
|
2026-08-20 00:11:08 +02:00
|
|
|
|
start := i * g.pageSize
|
|
|
|
|
|
end := start + g.pageSize
|
2026-07-02 22:20:28 +02:00
|
|
|
|
if end > total {
|
|
|
|
|
|
end = total
|
|
|
|
|
|
}
|
|
|
|
|
|
slice := photos[start:end]
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
|
|
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
|
|
|
|
return err
|
2026-07-02 22:20:28 +02:00
|
|
|
|
}
|
2026-09-28 07:10:29 +02:00
|
|
|
|
dst := filepath.Join(dir, "index.html")
|
2026-07-02 22:20:28 +02:00
|
|
|
|
|
|
|
|
|
|
prev := ""
|
|
|
|
|
|
next := ""
|
2026-09-28 07:10:29 +02:00
|
|
|
|
if i > 0 {
|
|
|
|
|
|
prev = pageURL(base, i)
|
2026-07-02 22:20:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
if i+1 < totalPages {
|
2026-09-28 07:10:29 +02:00
|
|
|
|
next = pageURL(base, i+2)
|
2026-07-02 22:20:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data := map[string]any{
|
|
|
|
|
|
"Photos": slice,
|
2026-09-28 07:10:29 +02:00
|
|
|
|
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
2026-07-02 22:20:28 +02:00
|
|
|
|
"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,
|
|
|
|
|
|
}
|
2026-08-20 00:11:08 +02:00
|
|
|
|
if err := g.render("grid.html", dst, data); err != nil {
|
2026-07-02 22:20:28 +02:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-28 08:22:18 +02:00
|
|
|
|
return g.prunePages(base, totalPages)
|
2026-07-02 00:53:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// renderPhotoPage writes public/photo/<slug>/index.html.
|
2026-07-03 23:03:54 +02:00
|
|
|
|
func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
|
2026-07-02 00:53:17 +02:00
|
|
|
|
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{
|
2026-07-02 02:30:35 +02:00
|
|
|
|
"Photo": p,
|
2026-07-03 23:03:54 +02:00
|
|
|
|
"Newer": newer,
|
|
|
|
|
|
"Older": older,
|
2026-07-02 02:30:35 +02:00
|
|
|
|
"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,
|
2026-07-02 00:53:17 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 23:46:28 +02:00
|
|
|
|
// groupByDay collapses photos (already sorted newest first) into per-day runs,
|
|
|
|
|
|
// preserving order. Two photos land in the same group iff their dates share a
|
|
|
|
|
|
// calendar day.
|
|
|
|
|
|
func groupByDay(photos []Photo) []DayGroup {
|
|
|
|
|
|
var groups []DayGroup
|
|
|
|
|
|
for _, p := range photos {
|
|
|
|
|
|
day := p.Date.Format("2006-01-02")
|
|
|
|
|
|
if n := len(groups); n > 0 && groups[n-1].day == day {
|
|
|
|
|
|
groups[n-1].Photos = append(groups[n-1].Photos, p)
|
|
|
|
|
|
groups[n-1].Count++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
groups = append(groups, DayGroup{
|
|
|
|
|
|
day: day,
|
|
|
|
|
|
Label: day, // ISO 2006-01-02
|
|
|
|
|
|
Count: 1,
|
|
|
|
|
|
Photos: []Photo{p},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 00:11:08 +02:00
|
|
|
|
// chunkDayGroups packs day groups into pages, filling each page until it holds
|
|
|
|
|
|
// at least target photos, then starting a new one. A single day is never split
|
|
|
|
|
|
// across a page boundary, so page sizes vary slightly around target. Always
|
|
|
|
|
|
// returns at least one page (empty when there are no photos) so the site root
|
|
|
|
|
|
// renders.
|
|
|
|
|
|
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
|
|
|
|
|
var pages [][]DayGroup
|
|
|
|
|
|
var cur []DayGroup
|
|
|
|
|
|
count := 0
|
|
|
|
|
|
for _, grp := range groups {
|
|
|
|
|
|
cur = append(cur, grp)
|
|
|
|
|
|
count += grp.Count
|
|
|
|
|
|
if count >= target {
|
|
|
|
|
|
pages = append(pages, cur)
|
|
|
|
|
|
cur, count = nil, 0
|
|
|
|
|
|
}
|
2026-08-19 23:46:28 +02:00
|
|
|
|
}
|
2026-08-20 00:11:08 +02:00
|
|
|
|
if len(cur) > 0 {
|
|
|
|
|
|
pages = append(pages, cur)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(pages) == 0 {
|
|
|
|
|
|
pages = [][]DayGroup{nil}
|
|
|
|
|
|
}
|
|
|
|
|
|
return pages
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 08:22:18 +02:00
|
|
|
|
// renderTimelinePages writes the day-grouped timeline: index.html and
|
|
|
|
|
|
// page/N/index.html under the timeline's base (the site root when the
|
|
|
|
|
|
// timeline is the default view). Photos are grouped by day, then packed
|
2026-08-20 00:11:08 +02:00
|
|
|
|
// into pages of roughly pageSize without splitting a day across pages.
|
|
|
|
|
|
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
|
|
|
|
|
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
|
|
|
|
|
totalPages := len(pages)
|
|
|
|
|
|
|
2026-09-28 07:10:29 +02:00
|
|
|
|
base := g.viewBase(viewTimeline)
|
|
|
|
|
|
|
2026-08-20 00:11:08 +02:00
|
|
|
|
for i, groups := range pages {
|
2026-09-28 07:10:29 +02:00
|
|
|
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
|
|
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
|
|
|
|
return err
|
2026-08-20 00:11:08 +02:00
|
|
|
|
}
|
2026-09-28 07:10:29 +02:00
|
|
|
|
dst := filepath.Join(dir, "index.html")
|
2026-08-20 00:11:08 +02:00
|
|
|
|
|
|
|
|
|
|
prev := ""
|
|
|
|
|
|
next := ""
|
2026-09-28 07:10:29 +02:00
|
|
|
|
if i > 0 {
|
|
|
|
|
|
prev = pageURL(base, i)
|
2026-08-20 00:11:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
if i+1 < totalPages {
|
2026-09-28 07:10:29 +02:00
|
|
|
|
next = pageURL(base, i+2)
|
2026-08-20 00:11:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data := map[string]any{
|
|
|
|
|
|
"Groups": groups,
|
2026-09-28 07:10:29 +02:00
|
|
|
|
"GridURL": pageURL(g.viewBase(viewGrid), 1),
|
2026-08-20 00:11:08 +02:00
|
|
|
|
"Total": len(photos),
|
|
|
|
|
|
"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("timeline.html", dst, data); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-28 08:22:18 +02:00
|
|
|
|
return g.prunePages(base, totalPages)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// prunePages removes the pager pages of a view past its last one. They are
|
|
|
|
|
|
// left behind when the photo count shrinks or the views swap places, and
|
|
|
|
|
|
// would keep showing photos that are gone.
|
|
|
|
|
|
func (g *Generator) prunePages(base string, totalPages int) error {
|
|
|
|
|
|
// Page 1 is the view's own root, never a leftover: start past it even
|
|
|
|
|
|
// if a caller reports no pages at all.
|
|
|
|
|
|
for n := max(totalPages, 1) + 1; ; n++ {
|
|
|
|
|
|
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
|
|
|
|
|
|
if _, err := os.Stat(dir); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-19 23:46:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 00:13:49 +02:00
|
|
|
|
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
2026-09-28 08:22:18 +02:00
|
|
|
|
// holding just that day's photos. Timeline date headings link here. Pages of
|
|
|
|
|
|
// days that no longer have photos, after a delete or a date change, are
|
|
|
|
|
|
// removed.
|
2026-08-20 00:13:49 +02:00
|
|
|
|
func (g *Generator) renderDayPages(photos []Photo) error {
|
2026-09-28 08:22:18 +02:00
|
|
|
|
groups := groupByDay(photos)
|
|
|
|
|
|
|
|
|
|
|
|
live := make(map[string]bool, len(groups))
|
|
|
|
|
|
for _, grp := range groups {
|
|
|
|
|
|
live[grp.day] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
dayDir := filepath.Join(g.outputDir, "day")
|
|
|
|
|
|
entries, err := os.ReadDir(dayDir)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
|
if e.IsDir() && !live[e.Name()] {
|
|
|
|
|
|
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, grp := range groups {
|
2026-08-20 00:13:49 +02:00
|
|
|
|
dir := filepath.Join(g.outputDir, "day", grp.day)
|
|
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
|
|
|
|
|
|
"Date": grp.Label,
|
2026-09-28 07:10:29 +02:00
|
|
|
|
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
2026-08-20 00:13:49 +02:00
|
|
|
|
"Count": grp.Count,
|
|
|
|
|
|
"Photos": grp.Photos,
|
|
|
|
|
|
"Total": len(photos),
|
|
|
|
|
|
"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 != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:53:17 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|