core/templates.go
Zeni Kim 19dba8f504 - Add function RenderNamedTemplate executes a named template from the registered set with the given data and returns the rendered HTML.
- gormlogger.Silent is the log level that suppresses all Gorm log messages, including the "record not found" warnings.
2026-05-11 20:17:30 -05:00

380 lines
11 KiB
Go

// Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
// 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"}}<a>{{.FieldA}}</a>{{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...))
}
// RenderNamedTemplate executes a named template from the registered set with
// the given data and returns the rendered HTML.
// Usage:
//
// html, err := core.RenderNamedTemplate("tabler_table", data)
// if err != nil {
// // handle error
// }
// return c.Response.HTML(string(html))
func RenderNamedTemplate(name string, data interface{}) (template.HTML, error) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", fmt.Errorf("failed to execute template %q: %w", name, err)
}
return template.HTML(buf.String()), nil
}