From 5e389115fc502055a23d021505803b672a6d472a Mon Sep 17 00:00:00 2001 From: Zeni Kim Date: Sat, 2 May 2026 03:00:56 -0500 Subject: [PATCH] Custom `html/template` FuncMap extensions that bring Liquid-like expressiveness to Go server-side templates. --- templates.go | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 331 insertions(+), 5 deletions(-) diff --git a/templates.go b/templates.go index 38ef4f2..4ee0050 100644 --- a/templates.go +++ b/templates.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Zeni Kim +// Copyright (c) 2026 Zeni Kim // Use of this source code is governed by MIT-style // license that can be found in the LICENSE file. @@ -6,15 +6,337 @@ 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) { - // templates var paths []string fs.WalkDir(components, ".", func(path string, d fs.DirEntry, err error) error { if strings.Contains(d.Name(), ".html") { @@ -31,6 +353,10 @@ func NewTemplates(components embed.FS, templates embed.FS) { return nil }) - tmpla := template.Must(template.ParseFS(components, paths...)) - tmpl = template.Must(tmpla.ParseFS(templates, pathst...)) -} + tmpl = template.Must( + template.New(""). + Funcs(funcMap()). + ParseFS(components, paths...), + ) + tmpl = template.Must(tmpl.ParseFS(templates, pathst...)) +} \ No newline at end of file