Files

466 lines
14 KiB
Go

// Package build renders a loaded site into a folder of static files.
package build
import (
"bytes"
"errors"
"fmt"
"html/template"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forms"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// marker is written into every output folder. A build only ever replaces a
// folder that has one, so pointing --out at the wrong place cannot wipe it.
const marker = ".hotdog-cms-build"
// Options for one build.
type Options struct {
SiteDir string
Out string // default <site>/public
Drafts bool
URL string // replace site.yaml's url (local preview)
Dev bool
Now time.Time // judge scheduled pages against this time; default now
Look map[string]any // replace site.yaml's look: (the editor's Look preview)
Forms map[string][]byte // replace forms/<name>.yaml (the editor's form preview)
}
// Result says what a build produced.
type Result struct {
Site *site.Site
Out string
Pages int
Files int
Feeds []string
Duration time.Duration
}
// Run builds a site.
func Run(opt Options) (*Result, error) {
start := time.Now()
siteDir, err := filepath.Abs(opt.SiteDir)
if err != nil {
return nil, err
}
out := opt.Out
if out == "" {
out = filepath.Join(siteDir, "public")
}
if out, err = filepath.Abs(out); err != nil {
return nil, err
}
if err := safeOut(siteDir, out); err != nil {
return nil, err
}
s, err := site.Load(siteDir, site.Options{Drafts: opt.Drafts, URL: opt.URL, Dev: opt.Dev, Now: opt.Now})
if err != nil {
return nil, err
}
// Built beside the output and swapped in at the end, so a build that
// fails halfway leaves the previous site untouched.
work := fmt.Sprintf("%s.new-%d", out, os.Getpid())
_ = os.RemoveAll(work)
defer os.RemoveAll(work)
if err := os.MkdirAll(work, 0o755); err != nil {
return nil, err
}
cards := assignCards(s)
tpl, err := prepare(siteDir, s, work)
if err != nil {
return nil, err
}
if err := writeCards(siteDir, work, s, cards); err != nil {
return nil, err
}
if err := copyTree(filepath.Join(siteDir, "static"), work, nil); err != nil {
return nil, fmt.Errorf("static: %w", err)
}
// Images and downloads kept beside a page in content/ go out beside it.
if err := copyTree(filepath.Join(siteDir, "content"), work, func(rel string, d fs.DirEntry) bool {
return !d.IsDir() && strings.HasSuffix(rel, ".md")
}); err != nil {
return nil, fmt.Errorf("content: %w", err)
}
res := &Result{Site: s, Out: out}
for _, p := range s.Pages {
name := p.Source
if name == "" {
name = p.Path
} else {
name = "content/" + name
}
if p.RedirectTo != "" {
dst := pageFile(work, p.Path)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := redirectTpl.Execute(&buf, p.RedirectTo); err != nil {
return nil, err
}
if err := os.WriteFile(dst, buf.Bytes(), 0o644); err != nil {
return nil, err
}
res.Pages++
continue
}
layout, err := tpl.layoutFor(p, s.Config)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if err := tpl.checkSections(p); err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
buf := &capped{max: maxPage}
if err := tpl.layouts[layout].ExecuteTemplate(buf, "base", p); err != nil {
return nil, fmt.Errorf("%s (layout %s): %w", name, layout, err)
}
dst := pageFile(work, p.Path)
if _, err := os.Stat(dst); err == nil {
return nil, fmt.Errorf("%s: %s is already taken by a file in static/ or content/", name, p.Path)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(dst, buf.Bytes(), 0o644); err != nil {
return nil, err
}
res.Pages++
}
cfg := s.Config
if err := writeCalendars(s, work); err != nil {
return nil, err
}
if cfg.Outputs.SitemapOn() {
if err := writeSitemap(s, work); err != nil {
return nil, err
}
}
if cfg.Outputs.RobotsOn() {
if _, err := os.Stat(filepath.Join(work, "robots.txt")); errors.Is(err, fs.ErrNotExist) {
if err := writeRobots(s, work); err != nil {
return nil, err
}
}
}
for name, cc := range cfg.Collections {
if cc.Search && s.Collection(name) != nil {
if err := writeSearch(s, name, work); err != nil {
return nil, fmt.Errorf("search index for %s: %w", name, err)
}
}
// A feed exists as soon as the collection does (its folder has a page
// of its own, or any pages), so links to it work before the first
// post: subscribers simply see nothing yet.
if !cc.Feed || (s.Collection(name) == nil && s.Lists[name] == nil) {
continue
}
write := writeFeed
if cc.FeedFormat == "rss" {
write = writeRSS
}
p, err := write(s, name, cc, work)
if err != nil {
return nil, fmt.Errorf("feed for %s: %w", name, err)
}
res.Feeds = append(res.Feeds, p)
}
if err := writeRedirects(s, work); err != nil {
return nil, err
}
if cfg.Outputs.LLMs {
if err := writeLLMs(s, work); err != nil {
return nil, err
}
}
if err := writeSearchFiles(work, s.Config); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(work, marker), []byte(s.BuildTime.Format(time.RFC3339)+"\n"), 0o644); err != nil {
return nil, err
}
_ = filepath.WalkDir(work, func(_ string, d fs.DirEntry, _ error) error {
if d != nil && !d.IsDir() {
res.Files++
}
return nil
})
if err := swap(work, out); err != nil {
return nil, err
}
res.Duration = time.Since(start)
return res, nil
}
// prepare fingerprints the assets (writing them into out, unless out is
// empty) and loads the templates with everything they can call.
func prepare(siteDir string, s *site.Site, out string) (*Templates, error) {
return prepareWith(siteDir, s, out, nil)
}
func prepareWith(siteDir string, s *site.Site, out string, formOverrides map[string][]byte) (*Templates, error) {
assets, err := writeAssets(filepath.Join(siteDir, "assets"), out)
if err != nil {
return nil, fmt.Errorf("assets: %w", err)
}
icons, err := loadIcons(filepath.Join(siteDir, "icons"))
if err != nil {
return nil, err
}
siteForms, err := forms.Load(siteDir)
if err != nil {
return nil, err
}
for name, raw := range formOverrides {
f, err := forms.Parse(name, raw)
if err != nil {
return nil, fmt.Errorf("forms/%s.yaml: %w", name, err)
}
siteForms[name] = f
}
if len(siteForms) > 0 {
if err := assets.add(out, "hotdog-forms.js", forms.Script); err != nil {
return nil, err
}
if err := assets.add(out, "hotdog-forms.css", forms.Style); err != nil {
return nil, err
}
}
if s.Config.Analytics != nil {
if err := assets.add(out, "hotdog-consent.js", consentScript); err != nil {
return nil, err
}
if err := assets.add(out, "hotdog-consent.css", consentStyle); err != nil {
return nil, err
}
}
// The site's look: a stylesheet of what it changes, after the theme's own.
look, err := site.LoadLook(siteDir)
if err != nil {
return nil, err
}
var lookVals map[string]site.LookValue
if look != nil {
if lookVals, err = look.Values(s.Config.Look); err != nil {
return nil, err
}
if css := look.CSS(lookVals); css != nil {
if err := assets.add(out, "look.css", css); err != nil {
return nil, err
}
}
} else if len(s.Config.Look) > 0 {
return nil, fmt.Errorf("site.yaml has look: but the site has no look.yaml saying what can change")
}
fm := funcs(s, assets, s.Markdown, icons)
// lookCSS is the look stylesheet's address, or "" when nothing's changed;
// look "logo" is a token's value, the site's choice or the theme's default.
fm["lookCSS"] = func() string {
u, _ := assets.URL("look.css")
return u
}
fm["consent"] = func() template.HTML { return consentTags(s.Config, assets) }
fm["verificationTags"] = func() template.HTML { return verificationTags(s.Config) }
fm["consentActive"] = func() bool { return s.Config.Analytics != nil }
fm["privacyFacts"] = func() []site.PrivacyFact { return privacyFacts(s.Config, siteForms) }
fm["look"] = func(name string) string {
if look == nil {
return ""
}
return look.Get(lookVals, name)
}
// form "contact" renders forms/contact.yaml.
fm["form"] = func(name string) (template.HTML, error) {
f, ok := siteForms[name]
if !ok {
return "", fmt.Errorf("no forms/%s.yaml", name)
}
js, _ := assets.URL("hotdog-forms.js")
css, _ := assets.URL("hotdog-forms.css")
return f.Render(js, css)
}
return loadTemplates(siteDir, fm)
}
// RenderPage renders one page of a site with its source replaced by content,
// without building the rest: the live preview of a page being edited. Asset
// addresses match a full build of the same site, so the page can be shown
// beside that build's files. It returns the HTML and the page's address.
func RenderPage(siteDir, source string, content []byte, opt Options) ([]byte, string, error) {
rel := strings.TrimPrefix(filepath.ToSlash(source), "content/")
s, err := site.Load(siteDir, site.Options{Drafts: true, URL: opt.URL, Now: opt.Now, Overrides: map[string][]byte{rel: content}, Look: opt.Look})
if err != nil {
return nil, "", err
}
assignCards(s)
tpl, err := prepareWith(siteDir, s, "", opt.Forms)
if err != nil {
return nil, "", err
}
for _, p := range s.Pages {
if p.Source != rel {
continue
}
if p.RedirectTo != "" {
var buf bytes.Buffer
err := redirectTpl.Execute(&buf, p.RedirectTo)
return buf.Bytes(), p.Path, err
}
layout, err := tpl.layoutFor(p, s.Config)
if err != nil {
return nil, "", err
}
if err := tpl.checkSections(p); err != nil {
return nil, "", err
}
buf := &capped{max: maxPage}
if err := tpl.layouts[layout].ExecuteTemplate(buf, "base", p); err != nil {
return nil, "", fmt.Errorf("layout %s: %w", layout, err)
}
return buf.Bytes(), p.Path, nil
}
return nil, "", fmt.Errorf("%s isn't a page of this site", source)
}
// maxPage bounds one rendered page. A template is something anyone who can
// write to the site can change, and a loop in one shouldn't be able to use
// up the memory of the machine building it (the editor builds everyone's
// drafts).
const maxPage = 32 << 20
// capped is a buffer that refuses to grow past max.
type capped struct {
bytes.Buffer
max int
}
func (c *capped) Write(p []byte) (int, error) {
if c.Len()+len(p) > c.max {
return 0, fmt.Errorf("the page is over %d MB; is a template looping?", c.max>>20)
}
return c.Buffer.Write(p)
}
func pageFile(root, urlPath string) string {
// path.Clean of an absolute path can't climb above it: whatever reaches
// here stays inside root.
clean := path.Clean("/" + urlPath)
if strings.HasSuffix(urlPath, "/") {
return filepath.Join(root, filepath.FromSlash(clean), "index.html")
}
return filepath.Join(root, filepath.FromSlash(clean))
}
// safeOut refuses output folders that would overwrite the site itself.
func safeOut(siteDir, out string) error {
rel, err := filepath.Rel(out, siteDir)
if err == nil && (rel == "." || !strings.HasPrefix(rel, "..")) {
return fmt.Errorf("output %s would contain the site itself; pick another folder", out)
}
for _, d := range []string{"content", "layouts", "partials", "sections", "assets", "static", "data", "forms", "icons"} {
if r, err := filepath.Rel(filepath.Join(siteDir, d), out); err == nil && !strings.HasPrefix(r, "..") {
return fmt.Errorf("output %s is inside the site's %s/ folder", out, d)
}
}
return nil
}
// claimed reports whether dir is missing, empty, or an earlier build.
func claimed(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return true, nil
}
if err != nil {
return false, err
}
if len(entries) == 0 {
return true, nil
}
_, err = os.Stat(filepath.Join(dir, marker))
return err == nil, nil
}
// swap puts the new build in place with two renames, so a web server reading
// the folder sees the old site or the new one, never half of each.
func swap(work, out string) error {
ok, err := claimed(out)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%s has files that hotdog-cms did not write; refusing to replace it", out)
}
old := fmt.Sprintf("%s.old-%d", out, os.Getpid())
if _, err := os.Stat(out); err == nil {
if err := os.Rename(out, old); err != nil {
return err
}
}
if err := os.Rename(work, out); err != nil {
_ = os.Rename(old, out)
return err
}
return os.RemoveAll(old)
}
// Install copies a finished build into dst the same way Run writes its own
// output: beside it first, then swapped in whole. dst must be empty, missing,
// or an earlier hotdog-cms build.
func Install(src, dst string) error {
if _, err := os.Stat(filepath.Join(src, marker)); err != nil {
return fmt.Errorf("%s is not a hotdog-cms build", src)
}
work := fmt.Sprintf("%s.new-%d", dst, os.Getpid())
_ = os.RemoveAll(work)
defer os.RemoveAll(work)
if err := copyTree(src, work, nil); err != nil {
return err
}
return swap(work, dst)
}
// Marker is the name of the file every build carries, for publishers that
// need to recognise a folder they may replace.
const Marker = marker
// CopyTree copies a folder as it is.
func CopyTree(src, dst string) error { return copyTree(src, dst, nil) }
// LookCSS is the look stylesheet a site would get with these choices, and the
// address a build would publish it at: for previewing a look before it's
// saved. Empty when the choices change nothing.
func LookCSS(siteDir string, look map[string]any) (string, []byte, error) {
lf, err := site.LoadLook(siteDir)
if err != nil || lf == nil {
return "", nil, err
}
vals, err := lf.Values(look)
if err != nil {
return "", nil, err
}
css := lf.CSS(vals)
if css == nil {
return "", nil, nil
}
a := &Assets{urls: map[string]string{}}
if err := a.add("", "look.css", css); err != nil {
return "", nil, err
}
u, _ := a.URL("look.css")
return u, css, nil
}