133 lines
4.0 KiB
Go
133 lines
4.0 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Banner is a notice across the top of the site: holiday hours, a closure,
|
|
// an offer. Banners live in data/banners.yaml, which the editor's quick
|
|
// posts write, and show only between from and until, if given.
|
|
//
|
|
// - text: Closed Thursday for Thanksgiving.
|
|
// link: /news/holiday-hours/
|
|
// link_text: Holiday hours
|
|
// tone: alert # info (the default) or alert
|
|
// show: home # all (the default) or home
|
|
// from: 2026-11-20 00:00
|
|
// until: 2026-11-27 00:00
|
|
type Banner struct {
|
|
Text string `yaml:"text" json:"text"`
|
|
Link string `yaml:"link,omitempty" json:"link,omitempty"`
|
|
LinkText string `yaml:"link_text,omitempty" json:"linkText,omitempty"`
|
|
Tone string `yaml:"tone,omitempty" json:"tone,omitempty"`
|
|
Show string `yaml:"show,omitempty" json:"show,omitempty"`
|
|
From string `yaml:"from,omitempty" json:"from,omitempty"`
|
|
Until string `yaml:"until,omitempty" json:"until,omitempty"`
|
|
|
|
Start, End time.Time `yaml:"-" json:"-"` // from and until, read in the site's time zone
|
|
}
|
|
|
|
// BannersFile is where banners are kept, in the site's folder.
|
|
const BannersFile = "data/banners.yaml"
|
|
|
|
// MaxBannerText keeps a banner a banner: one or two lines.
|
|
const MaxBannerText = 280
|
|
|
|
// LoadBanners reads data/banners.yaml, if there is one.
|
|
func LoadBanners(dir string, loc *time.Location) ([]Banner, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(BannersFile)))
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseBanners(raw, loc)
|
|
}
|
|
|
|
// ParseBanners reads and checks a banners file.
|
|
func ParseBanners(raw []byte, loc *time.Location) ([]Banner, error) {
|
|
var bs []Banner
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&bs); err != nil && !errors.Is(err, io.EOF) { // io.EOF: an empty file
|
|
return nil, fmt.Errorf("%s: %w", BannersFile, err)
|
|
}
|
|
for i := range bs {
|
|
if err := bs[i].Resolve(loc); err != nil {
|
|
return nil, fmt.Errorf("%s: banner %d: %w", BannersFile, i+1, err)
|
|
}
|
|
}
|
|
return bs, nil
|
|
}
|
|
|
|
// Resolve checks a banner and reads its times.
|
|
func (b *Banner) Resolve(loc *time.Location) error {
|
|
b.Text = strings.TrimSpace(b.Text)
|
|
switch {
|
|
case b.Text == "":
|
|
return fmt.Errorf("text is empty")
|
|
case len([]rune(b.Text)) > MaxBannerText:
|
|
return fmt.Errorf("text is over %d characters; put the rest on a page and link to it", MaxBannerText)
|
|
case strings.ContainsAny(b.Text, "\n\r"):
|
|
return fmt.Errorf("text should be one line")
|
|
}
|
|
switch b.Tone {
|
|
case "", "info", "alert":
|
|
default:
|
|
return fmt.Errorf("tone %q should be info or alert", b.Tone)
|
|
}
|
|
switch b.Show {
|
|
case "", "all", "home":
|
|
default:
|
|
return fmt.Errorf("show %q should be all or home", b.Show)
|
|
}
|
|
if b.Link != "" && !strings.HasPrefix(b.Link, "/") && !strings.HasPrefix(b.Link, "https://") && !strings.HasPrefix(b.Link, "http://") {
|
|
return fmt.Errorf("link %q should be a page on the site (/news/) or a web address", b.Link)
|
|
}
|
|
var err error
|
|
if b.From != "" {
|
|
if b.Start, _, err = eventTime(b.From, loc); err != nil {
|
|
return fmt.Errorf("from: %w", err)
|
|
}
|
|
}
|
|
if b.Until != "" {
|
|
if b.End, _, err = eventTime(b.Until, loc); err != nil {
|
|
return fmt.Errorf("until: %w", err)
|
|
}
|
|
if !b.Start.IsZero() && !b.End.After(b.Start) {
|
|
return fmt.Errorf("until is before from")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Active says whether the banner shows at t.
|
|
func (b *Banner) Active(t time.Time) bool {
|
|
return (b.Start.IsZero() || !t.Before(b.Start)) && (b.End.IsZero() || t.Before(b.End))
|
|
}
|
|
|
|
// Expired says whether the banner is over for good at t.
|
|
func (b *Banner) Expired(t time.Time) bool { return !b.End.IsZero() && !t.Before(b.End) }
|
|
|
|
// BannersFor is the banners showing on a page now.
|
|
func (s *Site) BannersFor(p *Page) []Banner {
|
|
var out []Banner
|
|
for _, b := range s.Banners {
|
|
if b.Active(s.Now) && (b.Show != "home" || (p != nil && p.IsHome())) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|