262 lines
8.5 KiB
Go
262 lines
8.5 KiB
Go
package build
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
|
|
)
|
|
|
|
var hugeWidth = regexp.MustCompile(`%[-+# 0]*(\*|\d{4,})|\.(\*|\d{4,})`)
|
|
|
|
func limitString(s string) (string, error) {
|
|
if len(s) > maxPage {
|
|
return "", errors.New("the result is too long")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// funcs is everything a template can call. There is deliberately no way to
|
|
// mark a string as trusted HTML: the only HTML that reaches a page unescaped
|
|
// is what came through the Markdown renderer, which applies the site's raw
|
|
// HTML rule. A template author cannot open a hole by accident.
|
|
func funcs(s *site.Site, a *Assets, md *site.Markdown, icons Icons) template.FuncMap {
|
|
hashes := map[string]string{} // fileHash results; pages render one at a time
|
|
m := template.FuncMap{
|
|
// asset "styles.css" -> /styles.3f9a1c2b07.css; an unknown name fails the build.
|
|
"asset": a.URL,
|
|
// absURL "/about/" -> https://example.org/about/
|
|
"absURL": func(p string) string {
|
|
if strings.Contains(p, "://") {
|
|
return p
|
|
}
|
|
return s.Config.URL + "/" + strings.TrimPrefix(p, "/")
|
|
},
|
|
// markdownify renders a front matter or data string as Markdown.
|
|
"markdownify": func(v any) (template.HTML, error) {
|
|
return md.Render([]byte(fmt.Sprint(v)))
|
|
},
|
|
// inline renders a short Markdown string without a paragraph around it.
|
|
"inline": func(v any) (template.HTML, error) {
|
|
return md.Inline([]byte(fmt.Sprint(v)))
|
|
},
|
|
// icon "search" "i" inlines icons/search.svg with that class.
|
|
"icon": icons.render,
|
|
// split "\n" .Data.heading, for a heading written over two lines.
|
|
"split": func(sep string, v any) []string { return strings.Split(strings.TrimRight(fmt.Sprint(v), "\n"), sep) },
|
|
// date "2 January 2006" .Date; "iso" gives 2006-01-02, "rfc3339" the full stamp.
|
|
"date": func(layout string, t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
switch layout {
|
|
case "iso":
|
|
return t.Format("2006-01-02")
|
|
case "rfc3339":
|
|
return t.Format("2006-01-02T15:04:05-07:00") // RFC 3339, with +00:00 rather than Z
|
|
}
|
|
return t.Format(layout)
|
|
},
|
|
// slugify takes anything, because front matter doesn't promise strings:
|
|
// `tags: [2013, Security]` has a number in it.
|
|
"slugify": func(v any) string { return site.Slugify(fmt.Sprint(v)) },
|
|
"collection": s.Collection,
|
|
// pageAt "/articles/x/": the page at an address, or nil.
|
|
"pageAt": s.PageAt,
|
|
"menu": s.Menu,
|
|
"data": func(name string) (any, error) {
|
|
v, ok := s.Data[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no data/%s.yaml or data/%s.json", name, name)
|
|
}
|
|
return v, nil
|
|
},
|
|
"limit": func(n int, list any) (any, error) {
|
|
v := reflect.ValueOf(list)
|
|
if v.Kind() != reflect.Slice {
|
|
return nil, errors.New("limit: not a list")
|
|
}
|
|
if n < v.Len() {
|
|
return v.Slice(0, n).Interface(), nil
|
|
}
|
|
return list, nil
|
|
},
|
|
"dict": func(kv ...any) (map[string]any, error) {
|
|
if len(kv)%2 != 0 {
|
|
return nil, errors.New("dict: needs key, value pairs")
|
|
}
|
|
m := make(map[string]any, len(kv)/2)
|
|
for i := 0; i < len(kv); i += 2 {
|
|
k, ok := kv[i].(string)
|
|
if !ok {
|
|
return nil, errors.New("dict: keys must be strings")
|
|
}
|
|
m[k] = kv[i+1]
|
|
}
|
|
return m, nil
|
|
},
|
|
"list": func(v ...any) []any { return v },
|
|
"append": func(l []any, v ...any) []any { return append(append([]any{}, l...), v...) },
|
|
// concat joins lists: concat .a .b
|
|
"concat": func(lists ...any) []any {
|
|
var out []any
|
|
for _, l := range lists {
|
|
if xs, ok := l.([]any); ok {
|
|
out = append(out, xs...)
|
|
}
|
|
}
|
|
return out
|
|
},
|
|
// hostOf "https://www.example.org/x" -> www.example.org
|
|
"hostOf": func(v any) string {
|
|
u, err := url.Parse(fmt.Sprint(v))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
},
|
|
"lower": strings.ToLower,
|
|
"upper": strings.ToUpper,
|
|
"join": strings.Join,
|
|
"contains": strings.Contains,
|
|
// hasPrefix "/img/" . and hasSuffix ".jpg" . (the affix first, like trimPrefix)
|
|
"hasPrefix": func(prefix, s string) bool { return strings.HasPrefix(s, prefix) },
|
|
"hasSuffix": func(suffix, s string) bool { return strings.HasSuffix(s, suffix) },
|
|
// cond test a b: a if test, else b.
|
|
"cond": func(test bool, a, b any) any {
|
|
if test {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
"trimPrefix": func(prefix, s string) string { return strings.TrimPrefix(s, prefix) },
|
|
"trimSuffix": func(suffix, s string) string { return strings.TrimSuffix(s, suffix) },
|
|
"replace": func(old, new, s string) string { return strings.ReplaceAll(s, old, new) },
|
|
"default": func(def, v any) any {
|
|
if v == nil || v == "" {
|
|
return def
|
|
}
|
|
return v
|
|
},
|
|
"now": func() time.Time { return s.BuildTime },
|
|
// kindIs "string" .x: what a front matter or data value is (string, map, list, number, bool).
|
|
"kindIs": func(kind string, v any) bool {
|
|
switch v.(type) {
|
|
case string:
|
|
return kind == "string"
|
|
case map[string]any:
|
|
return kind == "map"
|
|
case []any:
|
|
return kind == "list"
|
|
case int, int64, float64:
|
|
return kind == "number"
|
|
case bool:
|
|
return kind == "bool"
|
|
}
|
|
return false
|
|
},
|
|
// joinAny ", " .list: join a list of any values as text.
|
|
"joinAny": func(sep string, v []any) string {
|
|
parts := make([]string, len(v))
|
|
for i, x := range v {
|
|
parts[i] = fmt.Sprint(x)
|
|
}
|
|
return strings.Join(parts, sep)
|
|
},
|
|
"add": func(a, b int) int { return a + b },
|
|
"mod": func(a, b int) int { return a % b },
|
|
// excerpt 220 .Summary: plain text, cut at a word, with an ellipsis.
|
|
"excerpt": func(n int, v any) string { return site.Excerpt(n, fmt.Sprint(v)) },
|
|
// termURL "tags" "Open Source" -> /tags/open-source/ (or "" if it has none).
|
|
"termURL": s.TermURL,
|
|
// isExternal: an absolute link to another site.
|
|
"isExternal": md.External,
|
|
// Cloudflare's markers that turn Email Address Obfuscation off for a
|
|
// region. Fixed strings, so nothing a page writes can reach them.
|
|
"emailOff": func() template.HTML { return "<!--email_off-->" },
|
|
"emailOffEnd": func() template.HTML { return "<!--/email_off-->" },
|
|
// formatDate "January 2006" "2025-03-04": a date written as text.
|
|
"formatDate": func(layout string, v any) (string, error) {
|
|
str := strings.TrimSpace(fmt.Sprint(v))
|
|
if str == "" || v == nil {
|
|
return "", nil
|
|
}
|
|
t, err := time.Parse("2006-01-02", str)
|
|
if err != nil {
|
|
return "", fmt.Errorf("formatDate: %q is not a date like 2026-10-10", str)
|
|
}
|
|
return t.Format(layout), nil
|
|
},
|
|
// staticFile "/img/x.jpg": whether static/ has it.
|
|
"staticFile": func(p string) bool {
|
|
fi, err := os.Stat(staticPath(s.Dir, p))
|
|
return err == nil && !fi.IsDir()
|
|
},
|
|
// imageSize "/img/x.jpg" -> {w, h}, for og:image dimensions.
|
|
"imageSize": func(p string) (map[string]int, error) {
|
|
f, err := os.Open(staticPath(s.Dir, p))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
c, _, err := image.DecodeConfig(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("imageSize %s: %w", p, err)
|
|
}
|
|
return map[string]int{"w": c.Width, "h": c.Height}, nil
|
|
},
|
|
// fileHash "/img/x.jpg": 8 hex characters of the file's MD5, for a
|
|
// cache-busting ?v= on files in static/ that have no hashed name.
|
|
"fileHash": func(p string) (string, error) {
|
|
if h, ok := hashes[p]; ok {
|
|
return h, nil
|
|
}
|
|
b, err := os.ReadFile(staticPath(s.Dir, p))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := md5.Sum(b)
|
|
hashes[p] = hex.EncodeToString(sum[:])[:8]
|
|
return hashes[p], nil
|
|
},
|
|
// printf, print and println replace the template builtins with ones
|
|
// that won't build a huge string from a tiny format ("%0999999999d").
|
|
"printf": func(format string, args ...any) (string, error) {
|
|
if hugeWidth.MatchString(format) {
|
|
return "", errors.New("printf: widths and precisions over 999 aren't allowed")
|
|
}
|
|
return limitString(fmt.Sprintf(format, args...))
|
|
},
|
|
"print": func(args ...any) (string, error) { return limitString(fmt.Sprint(args...)) },
|
|
"println": func(args ...any) (string, error) { return limitString(fmt.Sprintln(args...)) },
|
|
"sectionContext": func(p *site.Page, sec site.Section) SectionContext {
|
|
return SectionContext{Type: sec.Type, Data: sec.Data, Page: p, Site: s}
|
|
},
|
|
}
|
|
for k, v := range eventFuncs(s) {
|
|
m[k] = v
|
|
}
|
|
return m
|
|
}
|
|
|
|
// staticPath maps a site path like /img/x.jpg into the site's static folder,
|
|
// never outside it.
|
|
func staticPath(siteDir, p string) string {
|
|
return filepath.Join(siteDir, "static", filepath.FromSlash(path.Clean("/"+p)))
|
|
}
|