internal/site/config.go

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