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