// Copyright (c) 2026 Zeni Kim // Use of this source code is governed by MIT-style // license that can be found in the LICENSE file. package core import ( "embed" "fmt" "html/template" "io/fs" "math" "reflect" "strings" "time" "unicode" "golang.org/x/text/language" "golang.org/x/text/message" ) var tmpl *template.Template = nil // ───────────────────────────────────────────── // Struct helpers // ───────────────────────────────────────────── // hasField checks if a struct has a given field name. // It can be used inside Go html/templates like: // // {{if hasField . "FieldA"}}{{.FieldA}}{{end}} func hasField(v interface{}, name string) bool { rv := reflect.ValueOf(v) if rv.Kind() == reflect.Ptr { rv = rv.Elem() } if rv.Kind() != reflect.Struct { return false } return rv.FieldByName(name).IsValid() } // ───────────────────────────────────────────── // String helpers // ───────────────────────────────────────────── // capitalize uppercases the first letter of a string and lowercases the rest. // // {{capitalize "hello world"}} → "Hello world" func capitalize(s string) string { if s == "" { return "" } runes := []rune(s) runes[0] = unicode.ToUpper(runes[0]) for i := 1; i < len(runes); i++ { runes[i] = unicode.ToLower(runes[i]) } return string(runes) } // prepend adds a prefix string to the beginning of s. // // {{prepend "World" "Hello, "}} → "Hello, World" func prepend(s, prefix string) string { return prefix + s } // strAppend adds a suffix string to the end of s. // Named strAppend to avoid collision with the builtin append keyword. // // {{strAppend "Hello" "!"}} → "Hello!" func strAppend(s, suffix string) string { return s + suffix } // split divides s into a slice of substrings separated by sep. // Useful when combined with range in templates. // // {{range split .Tags ","}}...{{end}} func split(s, sep string) []string { return strings.Split(s, sep) } // truncate cuts s to at most n characters. Designed for pipeline use: // {{.Title | truncate 30}} func truncate(n int, s string) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "…" } // ───────────────────────────────────────────── // Number helpers // ───────────────────────────────────────────── // fmtNumber formats an integer or float with thousands separators // using the English locale (e.g. 1000000 → "1,000,000"). // // {{fmtNumber .Price}} func fmtNumber(v interface{}) string { p := message.NewPrinter(language.English) switch n := v.(type) { case int: return p.Sprintf("%d", n) case int64: return p.Sprintf("%d", n) case float64: // trim unnecessary trailing zeros formatted := p.Sprintf("%.2f", n) return formatted case float32: return p.Sprintf("%.2f", n) default: return fmt.Sprintf("%v", v) } } // ───────────────────────────────────────────── // Date & time helpers // ───────────────────────────────────────────── // fmtDate formats a time.Time value using a named layout or a custom Go layout string. // Named layouts: "short" → "02 Jan 2006", "long" → "02 January 2006", // "iso" → "2006-01-02", "datetime" → "02 Jan 2006 15:04". // Any other string is used directly as a Go time layout. // // {{fmtDate .PublishedAt "short"}} // {{fmtDate .CreatedAt "02/01/2006"}} func fmtDate(t time.Time, layout string) string { switch layout { case "short": return t.Format("02 Jan 2006") case "long": return t.Format("02 January 2006") case "iso": return t.Format("2006-01-02") case "datetime": return t.Format("02 Jan 2006 15:04") default: return t.Format(layout) } } // timeAgo returns a human-readable relative time string from now. // // {{timeAgo .PublishedAt}} → "3 hours ago", "2 days ago", "just now" func timeAgo(t time.Time) string { diff := time.Since(t) switch { case diff < time.Minute: return "just now" case diff < time.Hour: m := int(math.Round(diff.Minutes())) return plural(m, "minute") + " ago" case diff < 24*time.Hour: h := int(math.Round(diff.Hours())) return plural(h, "hour") + " ago" case diff < 7*24*time.Hour: d := int(math.Round(diff.Hours() / 24)) return plural(d, "day") + " ago" case diff < 30*24*time.Hour: w := int(math.Round(diff.Hours() / 24 / 7)) return plural(w, "week") + " ago" case diff < 365*24*time.Hour: mo := int(math.Round(diff.Hours() / 24 / 30)) return plural(mo, "month") + " ago" default: y := int(math.Round(diff.Hours() / 24 / 365)) return plural(y, "year") + " ago" } } // plural is an internal helper that returns "1 item" or "N items". func plural(n int, unit string) string { if n == 1 { return fmt.Sprintf("%d %s", n, unit) } return fmt.Sprintf("%d %ss", n, unit) } // ───────────────────────────────────────────── // Collection helpers // ───────────────────────────────────────────── // first returns the first element of a slice, or nil if empty. // // {{with first .Items}}{{.Name}}{{end}} func first(slice interface{}) interface{} { rv := reflect.ValueOf(slice) if rv.Kind() != reflect.Slice || rv.Len() == 0 { return nil } return rv.Index(0).Interface() } // last returns the last element of a slice, or nil if empty. // // {{with last .Items}}{{.Name}}{{end}} func last(slice interface{}) interface{} { rv := reflect.ValueOf(slice) if rv.Kind() != reflect.Slice || rv.Len() == 0 { return nil } return rv.Index(rv.Len() - 1).Interface() } // sliceOf returns a sub-range of a slice from index start (inclusive) to end (exclusive). // Named sliceOf to avoid collision with the builtin slice expression. // // {{range sliceOf .Items 0 3}}...{{end}} func sliceOf(slice interface{}, start, end int) interface{} { rv := reflect.ValueOf(slice) if rv.Kind() != reflect.Slice { return nil } if start < 0 { start = 0 } if end > rv.Len() { end = rv.Len() } return rv.Slice(start, end).Interface() } // contains reports whether item is present in slice (or substr in a string). // // {{if contains .Tags "go"}}...{{end}} // {{if contains .Bio "engineer"}}...{{end}} func contains(collection, item interface{}) bool { // string substring check if s, ok := collection.(string); ok { if sub, ok := item.(string); ok { return strings.Contains(s, sub) } return false } rv := reflect.ValueOf(collection) if rv.Kind() != reflect.Slice { return false } for i := 0; i < rv.Len(); i++ { if reflect.DeepEqual(rv.Index(i).Interface(), item) { return true } } return false } // join concatenates the elements of a string slice with sep as separator. // // {{join .Tags ", "}} func join(slice []string, sep string) string { return strings.Join(slice, sep) } // ───────────────────────────────────────────── // Logic helpers // ───────────────────────────────────────────── // defaultVal returns fallback if value is the zero value or an empty string. // // {{defaultVal "N/A" .OptionalField}} func defaultVal(fallback, value interface{}) interface{} { if value == nil { return fallback } rv := reflect.ValueOf(value) if rv.IsZero() { return fallback } return value } // ternary returns trueVal if condition is true, falseVal otherwise. // // {{ternary "Active" "Inactive" .IsActive}} func ternary(trueVal, falseVal interface{}, condition bool) interface{} { if condition { return trueVal } return falseVal } // coalesce returns the first non-empty, non-nil value from the provided list. // // {{coalesce .Nickname .FullName "Anonymous"}} func coalesce(values ...interface{}) interface{} { for _, v := range values { if v == nil { continue } rv := reflect.ValueOf(v) if !rv.IsZero() { return v } } return nil } // ───────────────────────────────────────────── // FuncMap registry // ───────────────────────────────────────────── func funcMap() template.FuncMap { return template.FuncMap{ // Struct "hasField": hasField, // String "capitalize": capitalize, "prepend": prepend, "strAppend": strAppend, "split": split, "truncate": truncate, // Number "fmtNumber": fmtNumber, // Date & time "fmtDate": fmtDate, "timeAgo": timeAgo, // Collections "first": first, "last": last, "sliceOf": sliceOf, "contains": contains, "join": join, // Logic "defaultVal": defaultVal, "ternary": ternary, "coalesce": coalesce, } } // ───────────────────────────────────────────── // Template initializer // ───────────────────────────────────────────── func NewTemplates(components embed.FS, templates embed.FS) { var paths []string fs.WalkDir(components, ".", func(path string, d fs.DirEntry, err error) error { if strings.Contains(d.Name(), ".html") { paths = append(paths, path) } return nil }) var pathst []string fs.WalkDir(templates, ".", func(patht string, d fs.DirEntry, err error) error { if strings.Contains(d.Name(), ".html") { pathst = append(pathst, patht) } return nil }) tmpl = template.Must( template.New(""). Funcs(funcMap()). ParseFS(components, paths...), ) tmpl = template.Must(tmpl.ParseFS(templates, pathst...)) }