├── testdata ├── data │ └── sample.md ├── partials │ └── nav.html ├── views │ ├── layout │ │ └── user-login.html │ └── app │ │ ├── i18n.html │ │ └── dashboard.html ├── emails │ └── verify_en.txt ├── layout.html ├── app.html └── translations │ ├── en.json │ └── fr.json ├── go.mod ├── config.go ├── .github └── workflows │ ├── test.yml │ └── lint.yml ├── .gitignore ├── i18n.go ├── LICENSE ├── funcmap_test.go ├── cmd └── tpl │ ├── code.go │ └── main.go ├── translate.go ├── render_test.go ├── funcmap.go ├── render.go └── README.md /testdata/data/sample.md: -------------------------------------------------------------------------------- 1 | # Test Title -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dstpierre/tpl 2 | 3 | go 1.22.3 4 | -------------------------------------------------------------------------------- /testdata/partials/nav.html: -------------------------------------------------------------------------------- 1 | {{define "nav"}} 2 |
Main nav here
3 | {{end}} 4 | -------------------------------------------------------------------------------- /testdata/views/layout/user-login.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}Login title{{end}} {{define "content"}} 2 | 3 |This content is from a view page.
5 |{{.Data.Text}}
6 | {{end}} 7 | -------------------------------------------------------------------------------- /testdata/emails/verify_en.txt: -------------------------------------------------------------------------------- 1 | Please verify your email at: 2 | 3 | {{.Link}} 4 | 5 | This template can use custom function 6 | 7 | {{ abc }} 8 | 9 | And also all built-in functions: 10 | 11 | {{ "ok this is nice" | cut " " }} -------------------------------------------------------------------------------- /testdata/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |{{ tp .Lang "hello-people" 2 }}
6 | 7 |
8 | {{ shortdate .Locale .Data.Date }}
9 | {{ currency .Locale .Data.Amount }}
10 |
This template uses another base layout called "app.html"
5 |{{.Data.Text}}
6 | 7 |{{ abc }}
9 | 10 | Built-in directives: 11 | {{xsrf "xsrf-token-here"}} 12 | 13 | {{ "ok test 123" | cut " " }} 14 | {{ .CurrentUser | default "no user set" }} 15 | {{ 1234.0 | filesize }} 16 | {{ "a title that -- do have -" | slugify }} 17 | {{ index .Data.Numbers 0 | intcomma }} 18 | {{ .Data.Date | naturaltime }} 19 | {{end}} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | -------------------------------------------------------------------------------- /i18n.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // ToDate formats a date to a short date without time based on locale. 9 | func ToDate(locale string, date time.Time) string { 10 | layout := "01-02-2006" 11 | 12 | switch locale { 13 | case "fr-CA", "en-CA": 14 | layout = "02-01-2006" 15 | } 16 | 17 | return date.Format(layout) 18 | } 19 | 20 | // ToCurrency formats an amounts based on locale with the proper currency sign. 21 | func ToCurrency(locale string, amount float64) string { 22 | format := "$%.2f" 23 | 24 | switch locale { 25 | case "en-CA", "fr-CA": 26 | format = "%.2f $" 27 | } 28 | 29 | return fmt.Sprintf(format, amount) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dominic St-Pierre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | permissions: 11 | contents: read 12 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 13 | # pull-requests: read 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.22' 22 | cache: false 23 | - uses: actions/checkout@v3 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v3 26 | with: 27 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 28 | version: latest 29 | 30 | # Optional: working directory, useful for monorepos 31 | # working-directory: somedir 32 | 33 | # Optional: golangci-lint command line arguments. 34 | # args: --issues-exit-code=0 35 | args: --timeout=10m 36 | 37 | # Optional: show only new issues if it's a pull request. The default value is `false`. 38 | # only-new-issues: true 39 | 40 | # Optional: if set to true then the all caching functionality will be complete disabled, 41 | # takes precedence over all other caching options. 42 | # skip-cache: true 43 | 44 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 45 | # skip-pkg-cache: true 46 | 47 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 48 | # skip-build-cache: true 49 | -------------------------------------------------------------------------------- /funcmap_test.go: -------------------------------------------------------------------------------- 1 | package tpl_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestTranslationFunctions(t *testing.T) { 10 | templ := load(t) 11 | body := render(t, templ, "app/i18n.html") 12 | if !strings.Contains(body, "Bonjour personnes
") { 15 | t.Errorf("plural value not found in %s", body) 16 | } 17 | } 18 | 19 | func TestInternationalization(t *testing.T) { 20 | templ := load(t) 21 | body := render(t, templ, "app/i18n.html") 22 | 23 | nowInCA := time.Now().Format("02-01-2006") 24 | if !strings.Contains(body, ""+nowInCA+"") { 25 | t.Errorf("can't find Canadian date formatted: %s", body) 26 | } else if !strings.Contains(body, "1234.56 $") { 27 | t.Errorf("can't find Canadian currency formatted: %s", body) 28 | } 29 | } 30 | 31 | func TestBuiltIns(t *testing.T) { 32 | templ := load(t) 33 | body := render(t, templ, "app/dashboard.html") 34 | if !strings.Contains(body, ``) { 35 | t.Error("cannot find XSRF token input") 36 | } 37 | 38 | if !strings.Contains(body, "oktest123") { 39 | t.Error("cut does not work") 40 | } 41 | 42 | if !strings.Contains(body, "no user set") { 43 | t.Error("default does not work") 44 | } 45 | 46 | if !strings.Contains(body, "1.2 KB") { 47 | t.Error("filesize is not working") 48 | } 49 | 50 | if !strings.Contains(body, "a-title-that-do-have") { 51 | t.Error("slugify does not work") 52 | } 53 | 54 | } 55 | 56 | func TestHumanize(t *testing.T) { 57 | templ := load(t) 58 | body := render(t, templ, "app/dashboard.html") 59 | 60 | if !strings.Contains(body, "12,321") { 61 | t.Error("intcomma does not work") 62 | } 63 | 64 | if !strings.Contains(body, "5 minutes ago") { 65 | t.Error("naturaltime does not work") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/tpl/code.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "os" 9 | "strconv" 10 | ) 11 | 12 | var translationFuncs = map[string]bool{ 13 | "Translate": true, 14 | "TranslatePlural": true, 15 | "TranslateFormat": true, 16 | "TranslateFormatPlural": true, 17 | } 18 | 19 | func extractFromCode() ([]string, error) { 20 | var allKeys []string 21 | 22 | files, err := findAllTemplateFiles("./", "*.go") 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | for _, file := range files { 28 | b, err := os.ReadFile(file) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | keys, err := extractKeys("tmp.go", string(b)) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | allKeys = append(allKeys, keys...) 39 | } 40 | 41 | return allKeys, nil 42 | } 43 | 44 | func extractKeys(name, source string) ([]string, error) { 45 | fset := token.NewFileSet() 46 | 47 | f, err := parser.ParseFile(fset, name, source, 0) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | keys := []string{} 53 | 54 | ast.Inspect(f, func(n ast.Node) bool { 55 | callExpr, ok := n.(*ast.CallExpr) 56 | if !ok { 57 | return true 58 | } 59 | 60 | var funcName string 61 | switch fun := callExpr.Fun.(type) { 62 | case *ast.SelectorExpr: 63 | // Handles calls like 'tpl.Translate' 64 | funcName = fun.Sel.Name 65 | case *ast.Ident: 66 | // Handles direct calls like 'Translate' (if imported directly) 67 | funcName = fun.Name 68 | default: 69 | return true 70 | } 71 | 72 | if !translationFuncs[funcName] { 73 | return true 74 | } 75 | 76 | if len(callExpr.Args) < 2 { 77 | return true 78 | } 79 | 80 | keyArg := callExpr.Args[1] 81 | 82 | if basicLit, isLit := keyArg.(*ast.BasicLit); isLit && basicLit.Kind == token.STRING { 83 | cleanKey, err := strconv.Unquote(basicLit.Value) 84 | if err != nil { 85 | return true 86 | } 87 | keys = append(keys, cleanKey) 88 | } else { 89 | // This handles cases where the key is a variable or another function call (e.g., tpl.Translate("lang", myKey)) 90 | fmt.Printf("Warning: Key argument for %s is not a simple string literal (Type: %T). Skipping.\n", funcName, keyArg) 91 | } 92 | 93 | return true 94 | }) 95 | 96 | return keys, nil 97 | } 98 | -------------------------------------------------------------------------------- /translate.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | type Text struct { 13 | Key string `json:"key"` 14 | Value string `json:"value"` 15 | PluralValue string `json:"plural"` 16 | IsObsolete bool `json:"obsolete,omitempty"` 17 | } 18 | 19 | var messages map[string]Text 20 | 21 | func loadTranslations(fs embed.FS) error { 22 | messages = make(map[string]Text) 23 | 24 | files, err := load(fs, config.TemplateRootName, "translations") 25 | if err != nil { 26 | slog.Warn("loading translation files", "ERR", err) 27 | return nil 28 | } 29 | 30 | for _, file := range files { 31 | var msgs []Text 32 | b, err := fs.ReadFile(file.fullPath) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if err := json.Unmarshal(b, &msgs); err != nil { 38 | return err 39 | } 40 | 41 | fillTranslations(file.name, msgs) 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func fillTranslations(name string, msgs []Text) { 48 | lang := strings.TrimSuffix(name, filepath.Ext(name)) 49 | 50 | for _, msg := range msgs { 51 | key := fmt.Sprintf("%s_%s", lang, msg.Key) 52 | messages[key] = msg 53 | } 54 | } 55 | 56 | // GetMessageFromKey returns the Text structure for a giving language and key. 57 | func GetMessageFromKey(lang, key string) Text { 58 | k := fmt.Sprintf("%s_%s", lang, key) 59 | 60 | v, ok := messages[k] 61 | if !ok { 62 | return Text{Key: key, Value: "not found"} 63 | } 64 | 65 | return v 66 | } 67 | 68 | // Translate returns the proper value based on language and key. 69 | func Translate(lang, key string) string { 70 | return GetMessageFromKey(lang, key).Value 71 | } 72 | 73 | // TranslatePlural returns the proper version based on language, key, and number 74 | func TranslatePlural(lang, key string, num int64) string { 75 | msg := GetMessageFromKey(lang, key) 76 | if num > 1 && len(msg.PluralValue) > 0 { 77 | return msg.PluralValue 78 | } 79 | return msg.Value 80 | } 81 | 82 | // TranslateFormat returns the formatted text based on language and key 83 | func TranslateFormat(lang, key string, values []any) string { 84 | return fmt.Sprintf(GetMessageFromKey(lang, key).Value, values...) 85 | } 86 | 87 | // TranslateFormatPlural returns the proper formatted text based on language, 88 | // key, and number. 89 | func TranslateFormatPlural(lang, key string, num int64, values []any) string { 90 | s := TranslatePlural(lang, key, num) 91 | return fmt.Sprintf(s, values...) 92 | } 93 | -------------------------------------------------------------------------------- /render_test.go: -------------------------------------------------------------------------------- 1 | package tpl_test 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/dstpierre/tpl" 11 | ) 12 | 13 | //go:embed testdata/* 14 | var fsTest embed.FS 15 | 16 | var fmap map[string]any = map[string]any{ 17 | "abc": func() string { 18 | return "from custom func map" 19 | }, 20 | } 21 | 22 | func load(t *testing.T) *tpl.Template { 23 | opts := tpl.Option{TemplateRootName: "testdata"} 24 | tpl.Set(opts) 25 | 26 | templ, err := tpl.Parse(fsTest, fmap) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | return templ 32 | } 33 | 34 | type pagedata struct { 35 | Text string 36 | Date time.Time 37 | Amount float64 38 | Numbers []int64 39 | } 40 | 41 | func render(t *testing.T, templ *tpl.Template, view string) string { 42 | data := tpl.PageData{ 43 | Lang: "fr", 44 | Locale: "fr-CA", 45 | Title: "unit-test", 46 | Data: pagedata{ 47 | Text: "unit-test", 48 | Date: time.Now().Add(-5 * time.Minute), 49 | Amount: 1234.56, 50 | Numbers: []int64{12321, 3, 4, 2}, 51 | }, 52 | } 53 | var buf bytes.Buffer 54 | if err := templ.Render(&buf, view, data); err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | return buf.String() 59 | } 60 | 61 | func TestLoadTemplates(t *testing.T) { 62 | load(t) 63 | } 64 | 65 | func TestRender(t *testing.T) { 66 | templ := load(t) 67 | 68 | body := render(t, templ, "layout/user-login.html") 69 | if !strings.Contains(body, "unit-test
") { 70 | t.Errorf("body does not contains unit-test: %s", body) 71 | } 72 | } 73 | 74 | func TestAppLayoutNav(t *testing.T) { 75 | templ := load(t) 76 | 77 | body := render(t, templ, "app/dashboard.html") 78 | if !strings.Contains(body, "Main nav here
") { 79 | t.Errorf("can't find main nav in body: %s", body) 80 | } else if !strings.Contains(body, "func map") { 81 | t.Errorf("can't find func map in body: %s", body) 82 | } 83 | } 84 | 85 | func TestRenderEmail(t *testing.T) { 86 | type EmailData struct { 87 | Link string 88 | } 89 | 90 | data := EmailData{Link: "https://verify.com"} 91 | 92 | templ := load(t) 93 | 94 | var buf bytes.Buffer 95 | if err := templ.RenderEmail(&buf, "verify_en.txt", data); err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | body := buf.String() 100 | if !strings.Contains(body, "https://verify.com") { 101 | t.Errorf("can't find verify link in email body: %s", body) 102 | } else if !strings.Contains(body, "func map") { 103 | t.Errorf("can't find func map in body: %s", body) 104 | } 105 | } 106 | 107 | func TestRenderDataContent(t *testing.T) { 108 | templ := load(t) 109 | x, err := templ.GetDataContent("sample.md") 110 | if err != nil { 111 | t.Fatal(err) 112 | } else if string(x) != "# Test Title" { 113 | t.Errorf("reading data content got %s", string(x)) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /cmd/tpl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "regexp" 11 | 12 | "github.com/dstpierre/tpl" 13 | ) 14 | 15 | var keyRegex = regexp.MustCompile(`tp?\s+\.Lang\s+"([^"]+)"`) 16 | 17 | var ( 18 | rootPath string 19 | lang string 20 | ) 21 | 22 | func main() { 23 | flag.StringVar(&rootPath, "path", "", "templates root path") 24 | flag.StringVar(&lang, "lang", "", "Target language") 25 | flag.Parse() 26 | 27 | if len(rootPath) == 0 || len(lang) == 0 { 28 | flag.Usage() 29 | return 30 | } 31 | 32 | templateFiles, err := findAllTemplateFiles(rootPath, "*.html") 33 | if err != nil { 34 | fmt.Println(err) 35 | return 36 | } 37 | 38 | if len(templateFiles) == 0 { 39 | fmt.Println("No HTML template files found") 40 | return 41 | } 42 | 43 | allKeys := make(map[string]struct{}) 44 | for _, file := range templateFiles { 45 | keys, err := findKeysInFile(file) 46 | if err != nil { 47 | fmt.Printf("Error processing file %s: %v\n", file, err) 48 | continue 49 | } 50 | for key := range keys { 51 | allKeys[key] = struct{}{} 52 | } 53 | } 54 | 55 | codeKeys, err := extractFromCode() 56 | if err != nil { 57 | fmt.Println("error while parsing your Go code", err) 58 | return 59 | } 60 | 61 | for _, key := range codeKeys { 62 | allKeys[key] = struct{}{} 63 | } 64 | 65 | msgs, err := parseTargetFile(rootPath, lang) 66 | if err != nil { 67 | fmt.Println(err) 68 | 69 | } 70 | 71 | langKeys, err := getTargetKeys(msgs) 72 | if err != nil { 73 | fmt.Println(err) 74 | return 75 | } 76 | 77 | for key := range allKeys { 78 | if _, ok := langKeys[key]; !ok { 79 | msgs = append(msgs, tpl.Text{Key: key}) 80 | } 81 | } 82 | 83 | for idx, msg := range msgs { 84 | if _, ok := allKeys[msg.Key]; !ok { 85 | msgs[idx].IsObsolete = true 86 | } 87 | } 88 | 89 | if err := saveTargetFile(rootPath, lang, msgs); err != nil { 90 | fmt.Println(err) 91 | } 92 | } 93 | 94 | func findKeysInFile(filePath string) (map[string]struct{}, error) { 95 | content, err := os.ReadFile(filePath) 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) 98 | } 99 | 100 | keys := make(map[string]struct{}) 101 | matches := keyRegex.FindAllSubmatch(content, -1) 102 | 103 | for _, match := range matches { 104 | key := string(match[1]) 105 | keys[key] = struct{}{} 106 | } 107 | 108 | return keys, nil 109 | } 110 | 111 | func findAllTemplateFiles(rootPath string, pattern string) ([]string, error) { 112 | var files []string 113 | err := filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error { 114 | if err != nil { 115 | return err 116 | } 117 | 118 | if d.IsDir() { 119 | return nil 120 | } 121 | 122 | matched, err := filepath.Match(pattern, d.Name()) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | if matched { 128 | files = append(files, path) 129 | } 130 | return nil 131 | }) 132 | 133 | if err != nil { 134 | return nil, fmt.Errorf("error walking directory %s: %w", rootPath, err) 135 | } 136 | 137 | return files, nil 138 | } 139 | 140 | func parseTargetFile(rootPath, lang string) ([]tpl.Text, error) { 141 | b, err := os.ReadFile(path.Join(rootPath, "translations", lang+".json")) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | var msgs []tpl.Text 147 | if err := json.Unmarshal(b, &msgs); err != nil { 148 | return nil, err 149 | } 150 | 151 | return msgs, nil 152 | } 153 | 154 | func getTargetKeys(msgs []tpl.Text) (map[string]struct{}, error) { 155 | keys := make(map[string]struct{}) 156 | for _, msg := range msgs { 157 | keys[msg.Key] = struct{}{} 158 | } 159 | 160 | return keys, nil 161 | } 162 | 163 | func saveTargetFile(rootPath, lang string, msgs []tpl.Text) error { 164 | b, err := json.MarshalIndent(msgs, "", "\t") 165 | if err != nil { 166 | return fmt.Errorf("error converting to JSON: %w", err) 167 | } 168 | 169 | if err := os.WriteFile(path.Join(rootPath, "translations", lang+".json"), b, 0644); err != nil { 170 | return fmt.Errorf("error writing target file: %w", err) 171 | } 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /funcmap.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "reflect" 7 | "regexp" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func enhanceFuncMap(fmap map[string]any) { 13 | addTranslationFunctions(fmap) 14 | addInternationalizationFunctions(fmap) 15 | addHelperFunctions(fmap) 16 | addHumanizeFunctions(fmap) 17 | } 18 | 19 | func addTranslationFunctions(fmap map[string]any) { 20 | fmap["t"] = Translate 21 | fmap["tp"] = TranslatePlural 22 | fmap["tf"] = TranslateFormat 23 | fmap["tfp"] = TranslateFormatPlural 24 | } 25 | 26 | func addInternationalizationFunctions(fmap map[string]any) { 27 | fmap["shortdate"] = ToDate 28 | fmap["currency"] = ToCurrency 29 | } 30 | 31 | func addHelperFunctions(fmap map[string]any) { 32 | fmap["map"] = func(v ...any) map[string]any { 33 | if len(v)%2 != 0 { 34 | panic("call to map should have a key and value of even pairs") 35 | } 36 | 37 | m := make(map[string]any) 38 | for i := 0; i < len(v); i += 2 { 39 | key, ok := v[i].(string) 40 | if !ok { 41 | panic(fmt.Sprintf("key for the map function should be string: %v", v[i])) 42 | } 43 | 44 | m[key] = v[i+1] 45 | } 46 | 47 | return m 48 | } 49 | 50 | fmap["iterate"] = func(max uint) []uint { 51 | l := make([]uint, max) 52 | var idx uint 53 | for idx = 0; idx < max; idx++ { 54 | l[idx] = idx 55 | } 56 | return l 57 | } 58 | 59 | fmap["xsrf"] = func(token string) template.HTML { 60 | return template.HTML( 61 | fmt.Sprintf(``, token), 62 | ) 63 | } 64 | 65 | fmap["cut"] = func(v, s string) string { 66 | return strings.Replace(s, v, "", -1) 67 | } 68 | 69 | fmap["default"] = func(fallback, value any) any { 70 | if value == nil { 71 | return fallback 72 | } 73 | 74 | // Use reflect to check for zero values of various types 75 | val := reflect.ValueOf(value) 76 | switch val.Kind() { 77 | case reflect.String: 78 | if val.Len() == 0 { 79 | return fallback 80 | } 81 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 82 | if val.Int() == 0 { 83 | return fallback 84 | } 85 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: 86 | if val.Uint() == 0 { 87 | return fallback 88 | } 89 | case reflect.Float32, reflect.Float64: 90 | if val.Float() == 0.0 { 91 | return fallback 92 | } 93 | case reflect.Bool: 94 | if !val.Bool() { 95 | return fallback 96 | } 97 | case reflect.Slice, reflect.Map, reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr: 98 | if !val.IsValid() || val.IsNil() || (val.Kind() == reflect.Slice && val.Len() == 0) || (val.Kind() == reflect.Map && val.Len() == 0) { 99 | return fallback 100 | } 101 | default: 102 | // For any other type, if it's not nil, consider it "set" 103 | } 104 | 105 | return value 106 | } 107 | 108 | fmap["filesize"] = func(bytes float64) string { 109 | const ( 110 | KB = 1024.0 111 | MB = 1024.0 * KB 112 | GB = 1024.0 * MB 113 | TB = 1024.0 * GB 114 | PB = 1024.0 * TB 115 | ) 116 | 117 | switch { 118 | case bytes < KB: 119 | return fmt.Sprintf("%.0f B", bytes) 120 | case bytes < MB: 121 | return fmt.Sprintf("%.1f KB", bytes/KB) 122 | case bytes < GB: 123 | return fmt.Sprintf("%.1f MB", bytes/MB) 124 | case bytes < TB: 125 | return fmt.Sprintf("%.1f GB", bytes/GB) 126 | case bytes < PB: 127 | return fmt.Sprintf("%.1f TB", bytes/TB) 128 | default: 129 | return fmt.Sprintf("%.1f PB", bytes/PB) 130 | } 131 | } 132 | 133 | nonWordCharOrSpace := regexp.MustCompile(`[^\w\s-]`) 134 | multipleHyphens := regexp.MustCompile(`-+`) 135 | whitespace := regexp.MustCompile(`\s+`) 136 | 137 | fmap["slugify"] = func(s string) string { 138 | s = strings.ToLower(s) 139 | 140 | s = whitespace.ReplaceAllString(s, "-") 141 | s = nonWordCharOrSpace.ReplaceAllString(s, "") 142 | s = multipleHyphens.ReplaceAllString(s, "-") 143 | s = strings.Trim(s, "-") 144 | 145 | return s 146 | } 147 | 148 | } 149 | 150 | func addHumanizeFunctions(fmap map[string]any) { 151 | fmap["intcomma"] = func(i int64) string { 152 | s := fmt.Sprintf("%d", i) 153 | n := len(s) 154 | if n <= 3 { 155 | return s 156 | } 157 | 158 | // Calculate the position of the first comma 159 | firstComma := n % 3 160 | if firstComma == 0 { 161 | firstComma = 3 162 | } 163 | 164 | var result strings.Builder 165 | result.WriteString(s[:firstComma]) 166 | 167 | for j := firstComma; j < n; j += 3 { 168 | result.WriteString(",") 169 | result.WriteString(s[j : j+3]) 170 | } 171 | 172 | return result.String() 173 | } 174 | 175 | fmap["naturaltime"] = func(t time.Time) string { 176 | now := time.Now() 177 | diff := now.Sub(t) 178 | 179 | // Handle future dates (from now) 180 | if diff < 0 { 181 | diff = -diff 182 | if diff < 1*time.Minute { 183 | return "in " + formatDuration(diff) 184 | } else if diff < 1*time.Hour { 185 | minutes := int(diff.Minutes()) 186 | return fmt.Sprintf("in %d minute%s", minutes, plural(minutes)) 187 | } else if diff < 24*time.Hour { 188 | hours := int(diff.Hours()) 189 | return fmt.Sprintf("in %d hour%s", hours, plural(hours)) 190 | } else if diff < 30*24*time.Hour { // Roughly 30 days for a month 191 | days := int(diff.Hours() / 24) 192 | return fmt.Sprintf("in %d day%s", days, plural(days)) 193 | } else if diff < 365*24*time.Hour { // Roughly 365 days for a year 194 | months := int(diff.Hours() / (30 * 24)) // Approximate month 195 | return fmt.Sprintf("in %d month%s", months, plural(months)) 196 | } else { 197 | years := int(diff.Hours() / (365 * 24)) // Approximate year 198 | return fmt.Sprintf("in %d year%s", years, plural(years)) 199 | } 200 | } 201 | 202 | // Handle past dates (ago) 203 | if diff < 1*time.Minute { 204 | seconds := int(diff.Seconds()) 205 | if seconds < 10 { 206 | return "just now" 207 | } 208 | return fmt.Sprintf("%d second%s ago", seconds, plural(seconds)) 209 | } else if diff < 1*time.Hour { 210 | minutes := int(diff.Minutes()) 211 | return fmt.Sprintf("%d minute%s ago", minutes, plural(minutes)) 212 | } else if diff < 24*time.Hour { 213 | hours := int(diff.Hours()) 214 | return fmt.Sprintf("%d hour%s ago", hours, plural(hours)) 215 | } else if diff < 30*24*time.Hour { // Roughly 30 days for a month 216 | days := int(diff.Hours() / 24) 217 | return fmt.Sprintf("%d day%s ago", days, plural(days)) 218 | } else if diff < 365*24*time.Hour { // Roughly 365 days for a year 219 | months := int(diff.Hours() / (30 * 24)) // Approximate month 220 | return fmt.Sprintf("%d month%s ago", months, plural(months)) 221 | } else { 222 | years := int(diff.Hours() / (365 * 24)) // Approximate year 223 | return fmt.Sprintf("%d year%s ago", years, plural(years)) 224 | } 225 | } 226 | } 227 | 228 | func formatDuration(d time.Duration) string { 229 | if d < 1*time.Minute { 230 | seconds := int(d.Seconds()) 231 | if seconds < 10 { 232 | return "just now" 233 | } 234 | return fmt.Sprintf("%d second%s", seconds, plural(seconds)) 235 | } 236 | return "" 237 | } 238 | 239 | func plural(count int) string { 240 | if count == 1 { 241 | return "" 242 | } 243 | return "s" 244 | } 245 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | // package tpl handles structuring, parsing, and rendering of HTML templates. 2 | // It adds helpers, translations and internationalization functions. 3 | // 4 | // You must adopt the following structure for your templates: 5 | // Create a directory named "templates" and create the following structure. 6 | // 7 | // templates/emails 8 | // 9 | // templates/partials 10 | // templates/views/layout-name/page-name.html 11 | // templates/translations/en.json 12 | // templates/layout-name.html 13 | // 14 | // You create your base layouts at the root of the templates directory. 15 | // 16 | // Each layout must have a views/[layout_name_without_html] directory. 17 | // 18 | // Inside your layout files you define blocks that are filled from the views. 19 | // For example, in your layout: 20 | // 21 | //{{ t .Lang "unique key" }}
351 | 352 | Or for plural 353 | 354 |{{ tp .Lang "unique key" .Data }}
355 | 356 | .Data is 1234 in example above, so the plural value would be displayed. 357 | ``` 358 | 359 | There's helper function to display dates and currencies in the proper format based on `Locale`. 360 | 361 | ```go 362 | func home(w http.ResponseWriter, r *http.Request) { 363 | pdata := tpl.PageData{Lang: "fr", Locale: "fr-CA", Data: 59.99} 364 | if err := templ.Render(w, "layout/home.html", pdata); err != nil {} 365 | } 366 | ``` 367 | 368 | And inside the **templates/views/layout/home.html** file: 369 | 370 | ```html 371 |The price is {{ currency .Locale .Data }}
372 | ``` 373 | 374 | Display: The price is 59.99 $ 375 | 376 | If `Locale` is `en-US`: The price is $55.99. 377 | 378 | There's also a `{{ shortdate .Locale .Data.CreatedAt }}` helper function which formats a `time.Time` properly based on `Locale`. 379 | 380 | *NOTE: At this time there's only a limited amount of locale supported. If your locale isn't supported, please consider contributing the changes.* 381 | 382 | Translation functions are also exposes, so `tpl.Translate` can be call from your backend if you need translation outside of HTML templates. 383 | 384 | ## Passing a funcmap 385 | 386 | You may have helper functions you'd like to pass to the templates. Here's how: 387 | 388 | ```go 389 | package main 390 | 391 | import ( 392 | "embed" 393 | "github.com/dstpierre/tpl" 394 | ) 395 | 396 | //go:embed templates 397 | var fs embed.FS 398 | 399 | var templ *tpl.Template 400 | 401 | func main() { 402 | fmap := make(map[string]any) 403 | fmap["myfunc"] = func() string { return "hello" } 404 | r, err := tpl.Parse(fs, fmap) 405 | //... 406 | templ = t 407 | } 408 | ``` 409 | 410 | ## Built-in functions 411 | 412 | `tpl` adds the following functions to the funcmap. 413 | 414 | | function | description | 415 | |-----------|------------| 416 | | map | Create a map, useful to pass data to another template | 417 | | iterate | Allow you to iterate X numbers of time | 418 | | xsrf | Render an hidden input for your XSRF token | 419 | | cut | Remove chars from a string | 420 | | default | Display a fallback if input is nil or zero | 421 | | filesize | Display bytes size in KB, MB, GB, etc | 422 | | slugify | Turn a string into a slug | 423 | | intcomma | Adds , to thousands | 424 | | naturaltime | Display X minutes ago kind of output | 425 | 426 | Look at the `testdata/views/app/dashboard.html` for usage examples of these functions. --------------------------------------------------------------------------------