751 lines
19 KiB
Go
751 lines
19 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Site is everything the templates can see.
|
|
type Site struct {
|
|
Dir string
|
|
Config *Config
|
|
Pages []*Page // every page, in path order
|
|
Collections map[string][]*Page // collection -> its pages, in the collection's order
|
|
Lists map[string]*Page // collection -> its list page
|
|
Taxonomies map[string]map[string][]*Page
|
|
Data map[string]any // data/*.yaml and data/*.json, by file name without extension
|
|
BuildTime time.Time
|
|
Dev bool // built by `hotdog-cms serve`
|
|
// NextScheduled is the earliest moment a rebuild will change the site
|
|
// without a new commit: a publish_at still in the future, or an event
|
|
// ending (it leaves the upcoming lists).
|
|
NextScheduled time.Time
|
|
// Now is the time the build judges against: scheduled pages, upcoming
|
|
// events. Location is the site's time zone.
|
|
Now time.Time
|
|
Location *time.Location
|
|
// Banners are the notices from data/banners.yaml (banner.go).
|
|
Banners []Banner
|
|
Markdown *Markdown
|
|
terms map[string]*termIndex
|
|
}
|
|
|
|
// Options change what Load includes.
|
|
type Options struct {
|
|
Drafts bool // include pages with draft: true, and scheduled pages before their time
|
|
URL string // replace the configured URL (local preview)
|
|
Dev bool
|
|
Now time.Time // the time scheduled pages are judged against; default now
|
|
// Overrides replaces the content of source files (paths under content/,
|
|
// such as "about.md"), or adds files that don't exist yet: a page being
|
|
// edited, before it is saved.
|
|
Overrides map[string][]byte
|
|
// Skip leaves these source files out (a page about to be moved).
|
|
Skip map[string]bool
|
|
// Look, if not nil, replaces site.yaml's look: (the Look panel's preview).
|
|
Look map[string]any
|
|
}
|
|
|
|
// Load reads a whole site directory.
|
|
func Load(dir string, opt Options) (*Site, error) {
|
|
if err := NoLinks(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if opt.Look != nil {
|
|
cfg.Look = opt.Look
|
|
}
|
|
host := ""
|
|
if u, err := url.Parse(cfg.URL); err == nil {
|
|
host = u.Hostname() // the real site's host, even in a preview
|
|
}
|
|
if opt.URL != "" {
|
|
cfg.URL = strings.TrimRight(opt.URL, "/")
|
|
}
|
|
s := &Site{
|
|
Dir: dir,
|
|
Config: cfg,
|
|
Collections: map[string][]*Page{},
|
|
Lists: map[string]*Page{},
|
|
Taxonomies: map[string]map[string][]*Page{},
|
|
BuildTime: time.Now().UTC(),
|
|
Dev: opt.Dev,
|
|
}
|
|
now := opt.Now
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
if s.Location, err = cfg.Location(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.Now = now
|
|
if s.Banners, err = LoadBanners(dir, s.Location); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, b := range s.Banners {
|
|
s.schedule(b.Start, now)
|
|
s.schedule(b.End, now)
|
|
}
|
|
if s.Data, err = loadData(filepath.Join(dir, "data")); err != nil {
|
|
return nil, err
|
|
}
|
|
md := NewMarkdown(cfg.Markdown, host)
|
|
md.media = LoadMedia(filepath.Join(dir, "static"))
|
|
s.Markdown = md
|
|
content := filepath.Join(dir, "content")
|
|
seen := map[string]bool{}
|
|
// add takes one source file into the site, unless it is a draft or a
|
|
// page scheduled for later and this build leaves those out.
|
|
add := func(raw []byte, rel string) error {
|
|
pg, err := loadPage(raw, rel, md)
|
|
if err != nil {
|
|
return fmt.Errorf("content/%s: %w", rel, err)
|
|
}
|
|
if pg.Draft && !opt.Drafts {
|
|
return nil
|
|
}
|
|
if err := pg.resolveEvent(s.Location); err != nil {
|
|
return fmt.Errorf("content/%s: %w", rel, err)
|
|
}
|
|
if pg.Event != nil {
|
|
s.schedule(pg.Event.Ends(), now)
|
|
}
|
|
if !pg.PublishAt.IsZero() && pg.PublishAt.After(now) {
|
|
s.schedule(pg.PublishAt, now)
|
|
if !opt.Drafts {
|
|
return nil
|
|
}
|
|
}
|
|
pg.Site = s
|
|
s.Pages = append(s.Pages, pg)
|
|
return nil
|
|
}
|
|
err = filepath.WalkDir(content, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
|
|
return nil
|
|
}
|
|
r, _ := filepath.Rel(content, p)
|
|
rel := filepath.ToSlash(r)
|
|
if opt.Skip[rel] {
|
|
return nil
|
|
}
|
|
if o, ok := opt.Overrides[rel]; ok {
|
|
seen[rel] = true
|
|
return add(o, rel)
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return add(raw, rel)
|
|
})
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("no content/ folder in %s", dir)
|
|
}
|
|
return nil, err
|
|
}
|
|
// Overrides for files that don't exist yet: a new page, not yet saved.
|
|
for rel, raw := range opt.Overrides {
|
|
if seen[rel] {
|
|
continue
|
|
}
|
|
clean := path.Clean("/" + rel)[1:]
|
|
if clean != rel || !strings.HasSuffix(rel, ".md") {
|
|
return nil, fmt.Errorf("override %q is not a page source", rel)
|
|
}
|
|
if err := add(raw, rel); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
s.assemble()
|
|
if err := s.checkPaths(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ReadFile reads a file inside the site folder: rel is cleaned so it can't
|
|
// climb out ("../../etc/passwd" reads <site>/etc/passwd), it must be a
|
|
// regular file (not a link, not /dev/zero), and it may not be larger than
|
|
// max bytes.
|
|
func ReadFile(siteDir, rel string, max int64) ([]byte, error) {
|
|
clean := strings.TrimPrefix(path.Clean("/"+filepath.ToSlash(rel)), "/")
|
|
if clean == "" {
|
|
return nil, fmt.Errorf("%q isn't a file in the site", rel)
|
|
}
|
|
root, err := os.OpenRoot(siteDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer root.Close()
|
|
fi, err := root.Lstat(filepath.FromSlash(clean))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s isn't a regular file", clean)
|
|
}
|
|
if fi.Size() > max {
|
|
return nil, fmt.Errorf("%s is larger than %d MB", clean, max>>20)
|
|
}
|
|
f, err := root.Open(filepath.FromSlash(clean))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return io.ReadAll(io.LimitReader(f, max+1))
|
|
}
|
|
|
|
// NoLinks refuses a site that holds a symbolic link anywhere: a link
|
|
// committed to a site (static/x -> /etc/passwd, layouts/y -> ~/.ssh/id_ed25519)
|
|
// would let anyone who can write to the repository make a build read, and
|
|
// publish, files from the machine it runs on. The site's own folder may be
|
|
// reached through a link; nothing inside it may be one.
|
|
func NoLinks(dir string) error {
|
|
root, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(root, p)
|
|
if d.IsDir() {
|
|
switch filepath.ToSlash(rel) {
|
|
case ".git", "public", "node_modules":
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symbolic link; HotDog CMS doesn't follow links in a site, since they can reach files outside it. Put the file itself there", filepath.ToSlash(rel))
|
|
}
|
|
if !d.Type().IsRegular() {
|
|
return fmt.Errorf("%s isn't a regular file", filepath.ToSlash(rel))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// schedule notes a moment the site changes by itself, if it's still ahead.
|
|
func (s *Site) schedule(t, now time.Time) {
|
|
if t.After(now) && (s.NextScheduled.IsZero() || t.Before(s.NextScheduled)) {
|
|
s.NextScheduled = t
|
|
}
|
|
}
|
|
|
|
var leadImageRe = regexp.MustCompile(`\A\s*!\[([^\]]*)\]\(([^)\s]+)\)`)
|
|
|
|
var frontMatterRe = regexp.MustCompile(`(?s)\A---\r?\n(.*?)\r?\n---\r?\n?`)
|
|
|
|
func loadPage(raw []byte, rel string, md *Markdown) (*Page, error) {
|
|
var err error
|
|
raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf"))
|
|
fm := map[string]any{}
|
|
body := raw
|
|
var eventStart, eventEnd string
|
|
if m := frontMatterRe.FindSubmatchIndex(raw); m != nil {
|
|
if err := yaml.Unmarshal(raw[m[2]:m[3]], &fm); err != nil {
|
|
simple, ok := simpleFrontMatter(string(raw[m[2]:m[3]]))
|
|
if !ok {
|
|
return nil, fmt.Errorf("front matter: %w", err)
|
|
}
|
|
fm = simple
|
|
} else if fm["start"] != nil {
|
|
// An event's times as written: YAML would read a bare
|
|
// timestamp as UTC, but they're in the site's time zone.
|
|
var ev struct{ Start, End yaml.Node }
|
|
if yaml.Unmarshal(raw[m[2]:m[3]], &ev) == nil {
|
|
eventStart, eventEnd = ev.Start.Value, ev.End.Value
|
|
}
|
|
}
|
|
if fm == nil {
|
|
fm = map[string]any{}
|
|
}
|
|
body = raw[m[1]:]
|
|
}
|
|
p := &Page{Kind: KindPage, Source: rel, Params: fm, Terms: map[string][]string{}, eventStart: eventStart, eventEnd: eventEnd}
|
|
if p.eventStart == "" && fm["start"] != nil {
|
|
p.eventStart, p.eventEnd = str(fm["start"]), str(fm["end"])
|
|
}
|
|
p.Title = str(fm["title"])
|
|
p.Summary = str(fm["summary"])
|
|
if p.Summary == "" {
|
|
p.Summary = str(fm["description"])
|
|
}
|
|
p.Layout = str(fm["layout"])
|
|
p.Image = str(fm["image"])
|
|
p.ImageAlt = str(fm["image_alt"])
|
|
p.RedirectTo = str(fm["redirect_to"])
|
|
p.Draft = truthy(fm["draft"])
|
|
p.NoIndex = truthy(fm["noindex"])
|
|
if w, ok := fm["weight"].(int); ok {
|
|
p.Weight = w
|
|
}
|
|
if p.Date, err = date(fm["date"]); err != nil {
|
|
return nil, fmt.Errorf("date: %w", err)
|
|
}
|
|
if p.Updated, err = date(fm["updated"]); err != nil {
|
|
return nil, fmt.Errorf("updated: %w", err)
|
|
}
|
|
if p.PublishAt, err = date(fm["publish_at"]); err != nil {
|
|
return nil, fmt.Errorf("publish_at: %w", err)
|
|
}
|
|
if p.Sections, err = sections(fm["sections"]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir, name := path.Split(rel)
|
|
dir = strings.TrimSuffix(dir, "/")
|
|
base := strings.TrimSuffix(name, ".md")
|
|
switch {
|
|
case rel == "index.md":
|
|
p.Path = "/"
|
|
case rel == "404.md":
|
|
p.Kind, p.Path, p.NoIndex = KindError, "/404.html", true
|
|
case base == "_index":
|
|
p.Kind, p.Path = KindList, "/"+dir+"/"
|
|
case base == "index":
|
|
p.Path = "/" + dir + "/"
|
|
default:
|
|
slug := base
|
|
if s := str(fm["slug"]); s != "" {
|
|
slug = s
|
|
}
|
|
p.Path = "/" + path.Join(dir, slug) + "/"
|
|
}
|
|
if u := str(fm["url"]); u != "" && p.Kind == KindPage {
|
|
if !strings.HasPrefix(u, "/") || strings.Contains(u, "..") {
|
|
return nil, fmt.Errorf("url %q must be a path starting with /", u)
|
|
}
|
|
if !strings.HasSuffix(u, "/") && !strings.HasSuffix(u, ".html") {
|
|
u += "/"
|
|
}
|
|
p.Path = u
|
|
}
|
|
if dir != "" {
|
|
p.Collection = strings.SplitN(dir, "/", 2)[0]
|
|
}
|
|
|
|
switch str(fm["format"]) {
|
|
case "", "markdown":
|
|
if p.Content, err = md.Render(body); err != nil {
|
|
return nil, err
|
|
}
|
|
case "html":
|
|
if p.Content, err = md.HTML(body); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("format %q is not markdown or html", str(fm["format"]))
|
|
}
|
|
p.Raw = string(body)
|
|
p.WordCount = CountWords(PlainMarkdown(p.Raw))
|
|
if p.Title == "" {
|
|
p.Title = firstHeading(p.Raw)
|
|
}
|
|
if m := leadImageRe.FindStringSubmatch(p.Raw); m != nil {
|
|
p.LeadImage = &LeadImage{Src: m[2], Alt: m[1]}
|
|
}
|
|
if p.Summary == "" {
|
|
p.Summary = firstLine(p.Raw)
|
|
}
|
|
if p.Title == "" {
|
|
return nil, fmt.Errorf("title is required")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
var simpleLineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$`)
|
|
|
|
// simpleFrontMatter reads front matter that is one `key: value` per line but
|
|
// not valid YAML, which is how people write it by hand: `tags: [#Linux, #DevOps]`
|
|
// is a comment to YAML and a list of two tags to everyone else. Values in
|
|
// [brackets] are lists; quotes around a value are dropped. Anything more
|
|
// elaborate than that is left to YAML and its error message.
|
|
func simpleFrontMatter(src string) (map[string]any, bool) {
|
|
out := map[string]any{}
|
|
for _, line := range strings.Split(src, "\n") {
|
|
line = strings.TrimRight(line, "\r ")
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
m := simpleLineRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return nil, false
|
|
}
|
|
key, val := m[1], strings.TrimSpace(m[2])
|
|
if strings.HasPrefix(val, "[") && strings.HasSuffix(val, "]") {
|
|
var list []any
|
|
for _, item := range strings.Split(val[1:len(val)-1], ",") {
|
|
if item = unquote(strings.TrimSpace(item)); item != "" {
|
|
list = append(list, item)
|
|
}
|
|
}
|
|
out[key] = list
|
|
continue
|
|
}
|
|
switch val = unquote(val); val {
|
|
case "true":
|
|
out[key] = true
|
|
case "false":
|
|
out[key] = false
|
|
default:
|
|
out[key] = val
|
|
}
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func unquote(s string) string {
|
|
if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"' || s[0] == '\'' && s[len(s)-1] == '\'') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// assemble works out what depends on the whole set: collection order,
|
|
// neighbours, taxonomies, permalinks, and the list pages nobody wrote.
|
|
func (s *Site) assemble() {
|
|
cfg := s.Config
|
|
for _, p := range s.Pages {
|
|
p.Permalink = cfg.URL + p.Path
|
|
switch p.Kind {
|
|
case KindList:
|
|
s.Lists[p.Collection] = p
|
|
case KindPage:
|
|
if p.Collection != "" {
|
|
s.Collections[p.Collection] = append(s.Collections[p.Collection], p)
|
|
}
|
|
}
|
|
}
|
|
for name, pages := range s.Collections {
|
|
by := cfg.Collections[name].Sort
|
|
if by == "" && cfg.Collections[name].Calendar {
|
|
by = "start"
|
|
}
|
|
sortPages(pages, by)
|
|
for i, p := range pages {
|
|
if i > 0 {
|
|
p.Prev = pages[i-1]
|
|
}
|
|
if i < len(pages)-1 {
|
|
p.Next = pages[i+1]
|
|
}
|
|
}
|
|
list, ok := s.Lists[name]
|
|
if !ok {
|
|
list = &Page{Kind: KindList, Title: titleCase(name), Path: "/" + name + "/", Collection: name,
|
|
Params: map[string]any{}, Terms: map[string][]string{}, Site: s}
|
|
list.Permalink = cfg.URL + list.Path
|
|
s.Lists[name] = list
|
|
s.Pages = append(s.Pages, list)
|
|
}
|
|
list.Pages = pages
|
|
}
|
|
|
|
s.assembleTaxonomies()
|
|
s.paginate()
|
|
sort.Slice(s.Pages, func(i, j int) bool { return s.Pages[i].Path < s.Pages[j].Path })
|
|
}
|
|
|
|
func (s *Site) checkPaths() error {
|
|
seen := map[string]string{}
|
|
for _, p := range s.Pages {
|
|
src := p.Source
|
|
if src == "" {
|
|
src = "(generated " + p.Kind + " page)"
|
|
}
|
|
if !SafeURLPath(p.Path) && p.Path != "/404.html" {
|
|
return fmt.Errorf("%s: the address %q isn't one a build can write safely (letters, digits, - _ . ~ and /, no ..)", src, p.Path)
|
|
}
|
|
if other, dup := seen[p.Path]; dup {
|
|
return fmt.Errorf("%s and %s both want %s", other, src, p.Path)
|
|
}
|
|
seen[p.Path] = src
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PageAt returns the page at a URL path, or nil.
|
|
func (s *Site) PageAt(path string) *Page {
|
|
for _, p := range s.Pages {
|
|
if p.Path == path {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Collection returns a collection's pages in order; nil if there is none.
|
|
func (s *Site) Collection(name string) []*Page { return s.Collections[name] }
|
|
|
|
// Menu returns a menu from site.yaml.
|
|
func (s *Site) Menu(name string) []MenuItem { return s.Config.Menus[name] }
|
|
|
|
func sortPages(pages []*Page, by string) {
|
|
sort.SliceStable(pages, func(i, j int) bool {
|
|
a, b := pages[i], pages[j]
|
|
switch by {
|
|
case "title":
|
|
return strings.ToLower(a.Title) < strings.ToLower(b.Title)
|
|
case "start": // events, soonest first; pages that aren't events after them
|
|
if (a.Event == nil) != (b.Event == nil) {
|
|
return a.Event != nil
|
|
}
|
|
if a.Event != nil && !a.Event.Start.Equal(b.Event.Start) {
|
|
return a.Event.Start.Before(b.Event.Start)
|
|
}
|
|
return a.Path < b.Path
|
|
case "weight":
|
|
if a.Weight != b.Weight {
|
|
return a.Weight < b.Weight
|
|
}
|
|
return strings.ToLower(a.Title) < strings.ToLower(b.Title)
|
|
default:
|
|
if !a.Date.Equal(b.Date) {
|
|
return a.Date.After(b.Date)
|
|
}
|
|
return a.Path < b.Path
|
|
}
|
|
})
|
|
}
|
|
|
|
func loadData(dir string) (map[string]any, error) {
|
|
out := map[string]any{}
|
|
entries, err := os.ReadDir(dir)
|
|
if os.IsNotExist(err) {
|
|
return out, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
ext := filepath.Ext(name)
|
|
key := strings.TrimSuffix(name, ext)
|
|
raw, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var v any
|
|
switch ext {
|
|
case ".yaml", ".yml":
|
|
err = yaml.Unmarshal(raw, &v)
|
|
case ".json":
|
|
err = json.Unmarshal(raw, &v)
|
|
default:
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("data/%s: %w", name, err)
|
|
}
|
|
if _, dup := out[key]; dup {
|
|
return nil, fmt.Errorf("data/%s: another file is already named %s", name, key)
|
|
}
|
|
out[key] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sections(v any) ([]Section, error) {
|
|
if v == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := v.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("sections: expected a list")
|
|
}
|
|
var out []Section
|
|
for i, item := range list {
|
|
switch it := item.(type) {
|
|
case string:
|
|
out = append(out, Section{Type: it, Data: map[string]any{}})
|
|
case map[string]any:
|
|
if len(it) != 1 {
|
|
return nil, fmt.Errorf("sections[%d]: each section is one name with its fields under it", i)
|
|
}
|
|
for name, fields := range it {
|
|
data, _ := fields.(map[string]any)
|
|
if fields != nil && data == nil {
|
|
return nil, fmt.Errorf("sections[%d] (%s): fields must be a map", i, name)
|
|
}
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
out = append(out, Section{Type: name, Data: data})
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("sections[%d]: expected a name or a map", i)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func str(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(t)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprint(t)
|
|
}
|
|
}
|
|
|
|
func truthy(v any) bool {
|
|
b, ok := v.(bool)
|
|
return ok && b
|
|
}
|
|
|
|
func strList(v any) []string {
|
|
switch t := v.(type) {
|
|
case []any:
|
|
out := make([]string, 0, len(t))
|
|
for _, x := range t {
|
|
if s := str(x); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
var out []string
|
|
for _, s := range strings.Split(t, ",") {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var dateLayouts = []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02"}
|
|
|
|
func date(v any) (time.Time, error) {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return time.Time{}, nil
|
|
case time.Time:
|
|
return t, nil
|
|
case string:
|
|
for _, l := range dateLayouts {
|
|
if d, err := time.Parse(l, strings.TrimSpace(t)); err == nil {
|
|
return d, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("%q is not a date (use 2026-10-10)", t)
|
|
}
|
|
return time.Time{}, fmt.Errorf("%v is not a date", v)
|
|
}
|
|
|
|
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
|
|
|
func plain(h string) string { return html.UnescapeString(tagRe.ReplaceAllString(h, " ")) }
|
|
|
|
// firstHeading returns the text of the body's first "# " heading.
|
|
func firstHeading(md string) string {
|
|
for _, line := range strings.Split(md, "\n") {
|
|
if t := strings.TrimSpace(line); strings.HasPrefix(t, "# ") {
|
|
return strings.TrimSpace(t[2:])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstLine is a summary for a page that has none: the first line of prose,
|
|
// skipping headings, code, images, quotes and lists, as plain text cut to 220
|
|
// characters.
|
|
func firstLine(md string) string {
|
|
for _, line := range strings.Split(md, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
skip := false
|
|
for _, pre := range []string{"#", "```", "!", ">", "-", "*"} {
|
|
if strings.HasPrefix(t, pre) {
|
|
skip = true
|
|
break
|
|
}
|
|
}
|
|
if !skip {
|
|
return Excerpt(220, t)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var paraRe = regexp.MustCompile(`(?s)<p>(.*?)</p>`)
|
|
|
|
func firstParagraph(h string) string {
|
|
for _, m := range paraRe.FindAllStringSubmatch(h, -1) {
|
|
t := strings.Join(strings.Fields(plain(m[1])), " ")
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if r := []rune(t); len(r) > 200 {
|
|
cut := string(r[:200])
|
|
if i := strings.LastIndex(cut, " "); i > 120 {
|
|
cut = cut[:i]
|
|
}
|
|
return cut + "…"
|
|
}
|
|
return t
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Slugify makes a URL segment: lower case, letters and digits, hyphens.
|
|
func Slugify(s string) string {
|
|
var b strings.Builder
|
|
dash := false
|
|
for _, r := range strings.ToLower(s) {
|
|
switch {
|
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
|
b.WriteRune(r)
|
|
dash = false
|
|
case b.Len() > 0 && !dash:
|
|
b.WriteByte('-')
|
|
dash = true
|
|
}
|
|
}
|
|
return strings.TrimSuffix(b.String(), "-")
|
|
}
|
|
|
|
func titleCase(s string) string {
|
|
s = strings.ReplaceAll(s, "-", " ")
|
|
if s == "" {
|
|
return s
|
|
}
|
|
r := []rune(s)
|
|
r[0] = unicode.ToUpper(r[0])
|
|
return string(r)
|
|
}
|