package templates import ( "embed" "fmt" "html/template" "strings" "time" "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/logging" "github.com/Masterminds/sprig" "github.com/google/uuid" "github.com/teacat/noire" ) const ( Dayish = time.Hour * 24 Weekish = Dayish * 7 Monthish = Dayish * 30 Yearish = Dayish * 365 ) //go:embed src var templateFs embed.FS var Templates map[string]*template.Template func Init() { Templates = make(map[string]*template.Template) files, _ := templateFs.ReadDir("src") for _, f := range files { if hasSuffix(f.Name(), ".html") { t := template.New(f.Name()) t = t.Funcs(sprig.FuncMap()) t = t.Funcs(HMNTemplateFuncs) t, err := t.ParseFS(templateFs, "src/layouts/*.html", "src/include/*.html", "src/"+f.Name()) if err != nil { logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") } Templates[f.Name()] = t } else if hasSuffix(f.Name(), ".css", ".js", ".xml") { t := template.New(f.Name()) t = t.Funcs(sprig.FuncMap()) t = t.Funcs(HMNTemplateFuncs) t, err := t.ParseFS(templateFs, "src/"+f.Name()) if err != nil { logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template") } Templates[f.Name()] = t } } } func hasSuffix(s string, suffixes ...string) bool { for _, suffix := range suffixes { if strings.HasSuffix(s, suffix) { return true } } return false } func names(ts []*template.Template) []string { result := make([]string, len(ts)) for i, t := range ts { result[i] = t.Name() } return result } //go:embed svg/* var SVGs embed.FS var HMNTemplateFuncs = template.FuncMap{ "add": func(a int, b ...int) int { for _, num := range b { a += num } return a }, "strjoin": func(strs ...string) string { return strings.Join(strs, "") }, "absolutedate": func(t time.Time) string { return t.UTC().Format("January 2, 2006, 3:04pm") }, "absoluteshortdate": func(t time.Time) string { return t.UTC().Format("January 2, 2006") }, "rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) }, "rfc1123": func(t time.Time) string { return t.UTC().Format(time.RFC1123) }, "alpha": func(alpha float64, color noire.Color) noire.Color { color.Alpha = alpha return color }, "brighten": func(amount float64, color noire.Color) noire.Color { return color.Tint(amount) }, "color2css": func(color noire.Color) template.CSS { return template.CSS(color.HTML()) }, "csrftoken": func(s Session) template.HTML { return template.HTML(fmt.Sprintf(``, auth.CSRFFieldName, s.CSRFToken)) }, "csrftokenjs": func(s Session) template.HTML { return template.HTML(fmt.Sprintf(`{ "field": "%s", "token": "%s" }`, auth.CSRFFieldName, s.CSRFToken)) }, "darken": func(amount float64, color noire.Color) noire.Color { return color.Shade(amount) }, "hex2color": func(hex string) (noire.Color, error) { if len(hex) < 6 { return noire.Color{}, fmt.Errorf("hex color was invalid: %v", hex) } return noire.NewHex(hex), nil }, "lightness": func(lightness float64, color noire.Color) noire.Color { h, s, _, a := color.HSLA() return noire.NewHSLA(h, s, lightness*100, a) }, "relativedate": func(t time.Time) string { // TODO: Support relative future dates // NOTE(asaf): Months and years aren't exactly accurate, but good enough for now I guess. str := func(primary int, primaryName string, secondary int, secondaryName string) string { result := fmt.Sprintf("%d %s", primary, primaryName) if primary != 1 { result += "s" } if secondary > 0 { result += fmt.Sprintf(", %d %s", secondary, secondaryName) if secondary != 1 { result += "s" } } return result + " ago" } delta := time.Now().Sub(t) if delta < time.Minute { return "Less than a minute ago" } else if delta < time.Hour { return str(int(delta.Minutes()), "minute", 0, "") } else if delta < Dayish { return str(int(delta/time.Hour), "hour", int((delta%time.Hour)/time.Minute), "minute") } else if delta < Weekish { return str(int(delta/Dayish), "day", int((delta%Dayish)/time.Hour), "hour") } else if delta < Monthish { return str(int(delta/Weekish), "week", int((delta%Weekish)/Dayish), "day") } else if delta < Yearish { return str(int(delta/Monthish), "month", int((delta%Monthish)/Weekish), "week") } else { return str(int(delta/Yearish), "year", int((delta%Yearish)/Monthish), "month") } }, "svg": func(name string) template.HTML { contents, err := SVGs.ReadFile(fmt.Sprintf("svg/%s.svg", name)) if err != nil { panic("SVG not found: " + name) } return template.HTML(contents) }, "static": func(filepath string) string { return hmnurl.BuildPublic(filepath, true) }, "staticnobust": func(filepath string) string { return hmnurl.BuildPublic(filepath, false) }, "statictheme": func(theme string, filepath string) string { return hmnurl.BuildTheme(filepath, theme, true) }, "staticthemenobust": func(theme string, filepath string) string { return hmnurl.BuildTheme(filepath, theme, false) }, "string2uuid": func(s string) string { return uuid.NewSHA1(uuid.NameSpaceURL, []byte(s)).URN() }, "timehtml": func(formatted string, t time.Time) template.HTML { iso := t.UTC().Format(time.RFC3339) return template.HTML(fmt.Sprintf(``, iso, formatted)) }, "noescape": func(str string) template.HTML { return template.HTML(str) }, // NOTE(asaf): Template specific functions: "projectcarddata": func(project Project, classes string) ProjectCardData { return ProjectCardData{ Project: &project, Classes: classes, } }, "imageselectordata": func(name string, src string, required bool) ImageSelectorData { return ImageSelectorData{ Name: name, Src: src, Required: required, } }, "mediaimage": func() TimelineItemMediaType { return TimelineItemMediaTypeImage }, "mediavideo": func() TimelineItemMediaType { return TimelineItemMediaTypeVideo }, "mediaaudio": func() TimelineItemMediaType { return TimelineItemMediaTypeAudio }, "mediaembed": func() TimelineItemMediaType { return TimelineItemMediaTypeEmbed }, }