internal/site/build.go

// 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
}