forked from goffee/core
362 lines
No EOL
10 KiB
Go
362 lines
No EOL
10 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...))
|
|
} |