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 "" }, "emailOffEnd": func() template.HTML { return "" }, // 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))) }