// Static marketing-site renderer: shared HTML partials assembled into a
// self-contained static tree at build time.
package site
import (
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"time"
)
// Build renders every page in src/pages into out, wrapping each one in
// src/base.html together with the shared partials in src/partials, and copies
// src/static verbatim into out/static.
//
// Pages and partials are plain html/template files. base.html defines "base"
// (the page skeleton) and pulls in the surrounding chrome via {{template
// "header" .}} and {{template "footer" .}}; each partial defines one of those
// components; each page defines "title" and "content". Because the partials are
// single files referenced by every page, shared chrome has exactly one source
// of truth — the point of assembling at build time rather than hand-copying it
// into each page.
//
// The output is fully static and self-contained: no template engine or server
// process is needed to serve it.
func Build(src, out string) error {
pagesDir := filepath.Join(src, "pages")
pages, err := filepath.Glob(filepath.Join(pagesDir, "*.html"))
if err != nil {
return err
}
if len(pages) == 0 {
return fmt.Errorf("site: no pages found in %s", pagesDir)
}
base := filepath.Join(src, "base.html")
partials, err := filepath.Glob(filepath.Join(src, "partials", "*.html"))
if err != nil {
return err
}
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
// Data available to every template. Year keeps footer copyright current
// without a per-page front-matter system.
data := map[string]any{"Year": time.Now().Year()}
for _, page := range pages {
files := append([]string{base, page}, partials...)
t, err := template.ParseFiles(files...)
if err != nil {
return fmt.Errorf("site: parsing %s: %w", filepath.Base(page), err)
}
name := filepath.Base(page)
f, err := os.Create(filepath.Join(out, name))
if err != nil {
return err
}
if err := t.ExecuteTemplate(f, "base", data); err != nil {
f.Close()
return fmt.Errorf("site: rendering %s: %w", name, err)
}
if err := f.Close(); err != nil {
return err
}
}
return copyDir(filepath.Join(src, "static"), filepath.Join(out, "static"))
}
// copyDir copies the tree at src into dst. A missing src is not an error: a
// site need not ship static assets.
func copyDir(src, dst string) error {
info, err := os.Stat(src)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("site: %s is not a directory", src)
}
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()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}