// Package site: the build orchestrator. main.go calls Build; everything else
// lives in sub-packages under internal/. I like keeping this file short
// enough to read in one pass so each step reads like a sentence.
package site
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
"mercemay.top/src/tilstream/internal/feed"
"mercemay.top/src/tilstream/internal/index"
"mercemay.top/src/tilstream/internal/render"
)
// Build reads the site at root, writes output to cfg.Output. Returns nil
// only after the full pipeline -- copy static, parse, render, feed, index --
// has succeeded. On error, the output directory may be half-populated; callers
// should either tolerate it or delete and retry.
func Build(root string, cfg Config) error {
start := time.Now()
outDir := cfg.Resolve(root, cfg.Output)
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
if err := copyStatic(cfg.Resolve(root, cfg.StaticDir), outDir); err != nil {
return fmt.Errorf("static: %w", err)
}
posts, err := loadPosts(cfg.Resolve(root, cfg.Contents), cfg.Drafts)
if err != nil {
return fmt.Errorf("posts: %w", err)
}
sort.Slice(posts, func(i, j int) bool { return posts[i].Date.After(posts[j].Date) })
tmpls, err := render.LoadTemplates(cfg.Resolve(root, cfg.TemplateDir))
if err != nil {
return fmt.Errorf("templates: %w", err)
}
for _, p := range posts {
if err := renderPost(outDir, p, tmpls); err != nil {
return fmt.Errorf("render %s: %w", p.Slug, err)
}
}
if err := renderIndex(outDir, posts, tmpls); err != nil {
return fmt.Errorf("index page: %w", err)
}
if err := renderTagPages(outDir, posts, tmpls, cfg); err != nil {
return fmt.Errorf("tag pages: %w", err)
}
feedPath := filepath.Join(outDir, strings.TrimPrefix(cfg.FeedPath, "/"))
if err := feed.Write(feedPath, posts, cfg.BaseURL); err != nil {
return fmt.Errorf("rss: %w", err)
}
atomPath := filepath.Join(outDir, strings.TrimPrefix(cfg.AtomPath, "/"))
if err := feed.WriteAtom(atomPath, posts, cfg.BaseURL, cfg.Author.Name, cfg.Author.Email); err != nil {
return fmt.Errorf("atom: %w", err)
}
docs := index.Build(posts)
if err := index.WriteJSON(filepath.Join(outDir, "search.json"), docs); err != nil {
return fmt.Errorf("search index: %w", err)
}
log.Printf("built %d posts in %s", len(posts), time.Since(start).Round(time.Millisecond))
return nil
}
func loadPosts(dir string, includeDrafts bool) ([]render.Post, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var out []render.Post
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
p, err := render.ParseFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if p.Draft && !includeDrafts {
continue
}
out = append(out, p)
}
return out, nil
}
func renderPost(outDir string, p render.Post, tmpls *render.Templates) error {
dest := filepath.Join(outDir, p.Slug+".html")
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
return tmpls.Execute(f, "post", map[string]any{
"Post": p,
"Body": render.HTML(render.Expand(p.Body)),
})
}
func renderIndex(outDir string, posts []render.Post, tmpls *render.Templates) error {
dest := filepath.Join(outDir, "index.html")
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
return tmpls.Execute(f, "index", map[string]any{"Posts": posts})
}
func renderTagPages(outDir string, posts []render.Post, tmpls *render.Templates, cfg Config) error {
byTag := map[string][]render.Post{}
for _, p := range posts {
for _, t := range p.Tags {
byTag[t] = append(byTag[t], p)
}
}
tagRoot := filepath.Join(outDir, "tags")
if err := os.MkdirAll(tagRoot, 0o755); err != nil {
return err
}
for tag, ps := range byTag {
if len(ps) > cfg.ItemsPerTag {
ps = ps[:cfg.ItemsPerTag]
}
f, err := os.Create(filepath.Join(tagRoot, tag+".html"))
if err != nil {
return err
}
if err := tmpls.Execute(f, "tag", map[string]any{"Tag": tag, "Posts": ps}); err != nil {
f.Close()
return err
}
f.Close()
}
return nil
}
func copyStatic(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil // no static dir is fine
}
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
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
}