// Package site owns the on-disk layout and user config. Config is a plain
// YAML file at the site root; I resisted the urge to add a TOML fallback
// because one format is enough.
package site
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Config is the parsed user config. Any field left empty falls back to the
// defaults applied by ApplyDefaults.
type Config struct {
Title string `yaml:"title"`
BaseURL string `yaml:"base_url"`
Author Author `yaml:"author"`
Contents string `yaml:"contents"` // posts dir, default "posts"
Output string `yaml:"output"` // build dir, default "public"
TemplateDir string `yaml:"templates"` // default "templates"
StaticDir string `yaml:"static"` // default "static"
FeedPath string `yaml:"feed_path"` // default "/feed.xml"
AtomPath string `yaml:"atom_path"` // default "/atom.xml"
ItemsPerTag int `yaml:"per_tag"` // default 50
Drafts bool `yaml:"drafts"` // flag mirror from CLI
}
// Author is a tiny nested struct so we can keep feed-author logic in one
// place.
type Author struct {
Name string `yaml:"name"`
Email string `yaml:"email"`
URL string `yaml:"url"`
}
// Load reads path and returns a validated, default-filled Config. Missing
// file returns a friendly error rather than os.PathError.
func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Config{}, fmt.Errorf("config: no file at %s (create one or pass -c)", path)
}
return Config{}, fmt.Errorf("config: read %s: %w", path, err)
}
var c Config
if err := yaml.Unmarshal(raw, &c); err != nil {
return Config{}, fmt.Errorf("config: parse %s: %w", path, err)
}
ApplyDefaults(&c)
if err := c.Validate(); err != nil {
return Config{}, err
}
return c, nil
}
// ApplyDefaults fills zero-valued fields with the documented defaults. It is
// safe to call on an already-populated Config; populated fields win.
func ApplyDefaults(c *Config) {
if c.Title == "" {
c.Title = "TIL"
}
if c.Contents == "" {
c.Contents = "posts"
}
if c.Output == "" {
c.Output = "public"
}
if c.TemplateDir == "" {
c.TemplateDir = "templates"
}
if c.StaticDir == "" {
c.StaticDir = "static"
}
if c.FeedPath == "" {
c.FeedPath = "/feed.xml"
}
if c.AtomPath == "" {
c.AtomPath = "/atom.xml"
}
if c.ItemsPerTag == 0 {
c.ItemsPerTag = 50
}
}
// Validate returns the first user-visible error found, or nil. It does not
// touch the filesystem -- only the field values themselves.
func (c Config) Validate() error {
if c.BaseURL == "" {
return errors.New("config: base_url is required")
}
if _, err := url.Parse(c.BaseURL); err != nil {
return fmt.Errorf("config: base_url invalid: %w", err)
}
if !strings.HasPrefix(c.FeedPath, "/") {
return errors.New("config: feed_path must start with /")
}
if !strings.HasPrefix(c.AtomPath, "/") {
return errors.New("config: atom_path must start with /")
}
if c.ItemsPerTag < 1 {
return errors.New("config: per_tag must be >= 1")
}
return nil
}
// Resolve returns an absolute path inside the site root.
func (c Config) Resolve(root, rel string) string {
if filepath.IsAbs(rel) {
return rel
}
return filepath.Join(root, rel)
}