feat(generator): add -default-view to choose timeline or grid as site root

The chosen view is published at /, the other under /timeline/ or
/grid/. Page paths, pager links and the cross-links between the views
are derived from the setting. The directory named after the default
view is removed on build, since it can only be a leftover from before
a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
fox
2026-09-30 05:53:12 +00:00
committed by fox
co-authored by Claude Fable 5.1
parent 4b85dba8e1
commit 52ff47217a
10 changed files with 202 additions and 39 deletions
+56 -33
View File
@@ -27,6 +27,11 @@ const (
mediumMaxSize = 1600
defaultPageSize = 60
// The two paginated views. The one chosen as -default-view is published
// at the site root, the other under /<name>/.
viewTimeline = "timeline"
viewGrid = "grid"
// 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
@@ -78,15 +83,19 @@ type Generator struct {
serifFamily string
mlFamily string
pageSize int
defaultView string
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
@@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
serifFamily: serifFamily,
mlFamily: mlFamily,
pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl,
}
}
@@ -110,6 +120,23 @@ func (g *Generator) initial() string {
return ""
}
// 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) + "/"
}
// 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 {
@@ -121,7 +148,7 @@ func (g *Generator) Build() error {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} {
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
@@ -133,6 +160,13 @@ func (g *Generator) Build() error {
}
}
// 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
}
if err := g.renderTimelinePages(photos); err != nil {
return err
}
@@ -417,7 +451,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
if totalPages == 0 {
totalPages = 1
}
gridDir := filepath.Join(g.outputDir, "grid")
base := g.viewBase(viewGrid)
for i := 0; i < totalPages; i++ {
start := i * g.pageSize
@@ -427,31 +461,24 @@ func (g *Generator) renderGridPages(photos []Photo) error {
}
slice := photos[start:end]
dst := filepath.Join(gridDir, "index.html")
if i > 0 {
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 = "/grid/"
default:
prev = "/grid/page/" + strconv.Itoa(i) + "/"
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = "/grid/page/" + strconv.Itoa(i+2) + "/"
next = pageURL(base, i+2)
}
data := map[string]any{
"Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
@@ -551,32 +578,27 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages)
base := g.viewBase(viewTimeline)
for i, groups := range pages {
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")
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
next = pageURL(base, i+2)
}
data := map[string]any{
"Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"Total": len(photos),
"Page": i + 1,
"TotalPages": totalPages,
@@ -610,6 +632,7 @@ func (g *Generator) renderDayPages(photos []Photo) error {
}
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count,
"Photos": grp.Photos,
"Total": len(photos),