├── 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 |

Login

4 |

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 | {{block "title" .}} Default title {{end}} 5 | 6 | 7 |
{{block "content" .}}{{end}}
8 | 9 | 10 | -------------------------------------------------------------------------------- /testdata/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{block "title" .}} Logged in to app {{end}} 5 | 6 | 7 | {{template "nav" .}} 8 | 9 |
{{block "content" .}}{{end}}
10 | 11 | 12 | -------------------------------------------------------------------------------- /testdata/translations/en.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "key": "hello-world", 3 | "value": "Hello world" 4 | }, { 5 | "key": "hello-people", 6 | "value": "Hello person", 7 | "plural": "Hello people" 8 | }, { 9 | "key": "formatted", 10 | "value": "There's %d person", 11 | "plural": "There's %d people" 12 | }] -------------------------------------------------------------------------------- /testdata/views/app/i18n.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 | 3 |

{{ t .Lang "hello-world" }}

4 | 5 |

{{ tp .Lang "hello-people" 2 }}

6 | 7 |

8 | {{ shortdate .Locale .Data.Date }}
9 | {{ currency .Locale .Data.Amount }} 10 |

11 | 12 | {{end}} 13 | -------------------------------------------------------------------------------- /testdata/translations/fr.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "key": "hello-world", 3 | "value": "Allo tout le monde" 4 | }, { 5 | "key": "hello-people", 6 | "value": "Bonjour personne", 7 | "plural": "Bonjour personnes" 8 | }, { 9 | "key": "formatted", 10 | "value": "Il y a %d personne.", 11 | "plural": "Il y a %d personnes." 12 | }] -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | type Option struct { 4 | TemplateRootName string 5 | } 6 | 7 | var config Option 8 | 9 | func init() { 10 | config = Option{ 11 | TemplateRootName: "templates", 12 | } 13 | } 14 | 15 | // Set overrides the default option. By default the template root name is 16 | // `templates`. 17 | func Set(opts Option) { 18 | config = opts 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.22 20 | 21 | - name: Test 22 | run: go test 23 | 24 | -------------------------------------------------------------------------------- /testdata/views/app/dashboard.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |

Dashboard

3 | 4 |

This template uses another base layout called "app.html"

5 |

{{.Data.Text}}

6 | 7 |

Using custom function

8 |

{{ 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, "

Allo tout le monde

") { 13 | t.Errorf("can't find hello-world transaltion: %s", body) 14 | } else 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 | // {{ block "title" . }} 22 | //
{{ block "content" .}}
23 | // 24 | // And inside your view in the templates/views/[layout name]/[view name].html: 25 | // 26 | // {{define "title"}}Title from the view{{end}} 27 | // {{define "content"}} 28 | //

Hello from view

29 | // {{end}} 30 | // 31 | // You'll need to call the `Parse` function when your program starts and 32 | // provide a `embed.FS` for your templates. 33 | // 34 | // //go:embed templates 35 | // var fs embed.FS 36 | // 37 | // var templ *tpl.Template 38 | // 39 | // func main() { 40 | // templ, err := tpl.Parse(fs, nil) 41 | // } 42 | // 43 | // When rendering a view you can optionally use the `PageData` structure or your own. 44 | // 45 | // func hello(w http.ResponseWriter, r *http.Request) { 46 | // data := tpl.PageData{ 47 | // Lang: "fr", // if needed, should match fr.json in translations dir 48 | // Locale: "fr-CA", // used to format dates and currency 49 | // Title: "Page title", // if you need this 50 | // CurrentUser: YourUser{}, // a handy field to hold the current user 51 | // Data: YourData{}, // this is what you'd normally sent to the Execute fn 52 | // } 53 | // if err := templ.Render(w, "app/hello.html", data); err != nil {} 54 | // } 55 | // 56 | // If you need to translate your template you may create JSON files per language 57 | // with the following structure: 58 | // 59 | // [{ 60 | // "key": "a unique key", 61 | // "value": "translation value", 62 | // "plural": "optional if plural is needed", 63 | // }] 64 | // 65 | // There's four different template function relative to translation: 66 | // 67 | // 1. {{ t .Lang "a unique key" }} 68 | // 69 | // 2. {{ tp .Lang "single or plural" 2 }} 70 | // 71 | // 3. {{ tf .Lang "a formatted" .Data.AnArray }} 72 | // 73 | // 4. {{ tpf .Lang "foramtted and pluralized" 2 .Data.AnArray }} 74 | package tpl 75 | 76 | import ( 77 | "embed" 78 | "errors" 79 | "fmt" 80 | "html/template" 81 | "io" 82 | "path" 83 | "path/filepath" 84 | "strings" 85 | ) 86 | 87 | // Template holds the file system and the parsed views. 88 | type Template struct { 89 | FS embed.FS 90 | Views map[string]*template.Template 91 | Emails map[string]*template.Template 92 | } 93 | 94 | // Parse parses and load the layouts, templates, partials, and optionally the 95 | // translation files. 96 | // 97 | // You should embed the templates in your program and pass the `embed.FS` to the 98 | // function. 99 | func Parse(fs embed.FS, funcMap map[string]any) (*Template, error) { 100 | if funcMap == nil { 101 | funcMap = make(map[string]any) 102 | } 103 | 104 | enhanceFuncMap(funcMap) 105 | 106 | if err := loadTranslations(fs); err != nil { 107 | return nil, err 108 | } 109 | 110 | partials, err := load(fs, config.TemplateRootName, "partials") 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | layouts, err := load(fs, config.TemplateRootName) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | viewsDir := path.Join(config.TemplateRootName, "views") 121 | views := make(map[string]*template.Template) 122 | 123 | for _, layout := range layouts { 124 | layoutView := strings.TrimSuffix(layout.name, filepath.Ext(layout.name)) 125 | 126 | pages, err := load(fs, viewsDir, layoutView) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | for _, view := range pages { 132 | viewName := fmt.Sprintf(layoutView+"/%s", view.name) 133 | 134 | tf := template.New(layout.name).Funcs(funcMap) 135 | 136 | patterns := []string{ 137 | layout.fullPath, 138 | view.fullPath, 139 | } 140 | 141 | patterns = append(patterns, getPaths(partials)...) 142 | 143 | t, err := tf.ParseFS( 144 | fs, 145 | patterns..., 146 | ) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | views[viewName] = t 152 | } 153 | } 154 | 155 | emails := make(map[string]*template.Template) 156 | 157 | emailFiles, err := load(fs, config.TemplateRootName, "emails") 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | for _, ef := range emailFiles { 163 | t, err := template.New(ef.name).Funcs(funcMap).ParseFS(fs, ef.fullPath) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | emails[ef.name] = t 169 | } 170 | 171 | templ := &Template{FS: fs, Views: views, Emails: emails} 172 | return templ, nil 173 | } 174 | 175 | type file struct { 176 | name string 177 | fullPath string 178 | } 179 | 180 | func load(fs embed.FS, dir ...string) ([]file, error) { 181 | var files []file 182 | 183 | fullDir := path.Join(dir...) 184 | 185 | if ok := exists(fs, fullDir); !ok { 186 | if strings.HasSuffix(fullDir, "_partials") { 187 | fmt.Println("tpl: You must have a `partials` directory created") 188 | } else if strings.HasSuffix(fullDir, "partials") { 189 | fmt.Println("tpl: obsolete name '_partials' must be changed to 'partials'.") 190 | dir[len(dir)-1] = "_partials" 191 | return load(fs, dir...) 192 | } 193 | 194 | return nil, nil 195 | } 196 | 197 | //TODO: might be an idea to un-hardcode the paths and have options 198 | allFiles, err := fs.ReadDir(fullDir) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | for _, f := range allFiles { 204 | if f.IsDir() { 205 | continue 206 | } 207 | 208 | files = append(files, file{name: f.Name(), fullPath: path.Join(fullDir, f.Name())}) 209 | } 210 | 211 | return files, nil 212 | } 213 | 214 | func getPaths(files []file) []string { 215 | var p []string 216 | for _, f := range files { 217 | p = append(p, f.fullPath) 218 | } 219 | return p 220 | } 221 | 222 | type Notification struct { 223 | Title template.HTML 224 | Message template.HTML 225 | IsSuccess bool 226 | IsError bool 227 | IsWarning bool 228 | } 229 | 230 | type PageData struct { 231 | Lang string 232 | Locale string 233 | Timezone string 234 | 235 | XSRFToken string 236 | 237 | Title string 238 | CurrentUser any 239 | Alert *Notification 240 | Data any 241 | Extra any 242 | 243 | Env string 244 | } 245 | 246 | // Render renders a template from a [layout]/[page.html]. 247 | // 248 | // The layout should not have the .html, so if you have 2 layouts one name 249 | // layout.html and one named app.html, a template named "dashboard.html" in the 250 | // app layout would be named: app/dashboard.html. 251 | func (templ *Template) Render(w io.Writer, view string, data any) error { 252 | v, ok := templ.Views[view] 253 | if !ok { 254 | return errors.New("can't find view: " + view) 255 | } 256 | 257 | return v.Execute(w, data) 258 | } 259 | 260 | // RenderEmail renders the email found in the templates/emails directory. 261 | // 262 | // You may create language specific templates and html and text version 263 | // as follow: templates/emails/verify_en.html, templates/emails/verify_fr.txt, etc. 264 | // 265 | // Note that this execution does not use the PageData struct, but the data 266 | // passed directly. 267 | func (templ *Template) RenderEmail(w io.Writer, email string, data any) error { 268 | e, ok := templ.Emails[email] 269 | if !ok { 270 | return errors.New("can't find email: " + email) 271 | } 272 | 273 | return e.Execute(w, data) 274 | } 275 | 276 | // exists returns whether the given file or directory exists 277 | func exists(fs embed.FS, path string) bool { 278 | f, err := fs.Open(path) 279 | if err != nil { 280 | return false 281 | } 282 | f.Close() 283 | return true 284 | } 285 | 286 | // GetDataContent returns the content of file in the data directory 287 | func (templ *Template) GetDataContent(filename string) ([]byte, error) { 288 | return templ.FS.ReadFile(path.Join(config.TemplateRootName, "data", filename)) 289 | } 290 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tpl 2 | 3 | ![build badge](https://github.com/dstpierre/tpl/actions/workflows/test.yml/badge.svg) 4 | [![GoReportCard](https://goreportcard.com/badge/github.com/dstpierre/tpl)](https://goreportcard.com/report/github.com/dstpierre/tpl) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/dstpierre/tpl.svg)](https://pkg.go.dev/github.com/dstpierre/tpl) 6 | 7 | 8 | > I was tired of having the same issues phase when starting a new web project with Go's `html/template`. 9 | 10 | `tpl` is an opinionated lightweight helper library that makes structuring, parsing, and rendering templates in Go more tolerable. It also adds small helpers, translations, and i18n functions to the `funcmap` of templates. 11 | 12 | **Table of content**: 13 | 14 | * [Installation](#installation) 15 | * [Usage](#usage) 16 | * [Template file structure](#template-file-structure) 17 | * [Parsing and rendering](#parsing-and-rendering) 18 | * [PageData structure](#pageData-structure) 19 | * [Example templates](#example-templates) 20 | * [Quick template example](#quick-template-example) 21 | * [Rendering emails](#rendering-emails) 22 | * [i18n](#i18n) 23 | * [Passing a funcmap](#passing-a-funcmap) 24 | * [Built-in functions]()#built-in-functions 25 | 26 | ## Installation 27 | 28 | ```sh 29 | $ go get github.com/dstpierre/tpl 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Template file structure 35 | 36 | To use this library, you'll need to adopt the following files and directory structure for your templates: 37 | 38 | Create a `templates` directory with the following structure: 39 | 40 | ``` 41 | templates/ 42 | ├── emails 43 | │   └── verify-email.html 44 | │   └── verify-email.txt 45 | ├── partials 46 | │   └── a-reusable-piece-1.html 47 | │   └── a-reusable-piece-2.html 48 | ├── app.html 49 | ├── layout.html 50 | ├── translations 51 | │   ├── en.json 52 | │   └── fr.json 53 | └── views 54 | ├── app 55 | │   ├── dashboard.html 56 | │   └── page-signed-in-user.html 57 | └── layout 58 | └── user-login.html 59 | ``` 60 | 61 | Now `app.html` and `layout.html` are **example names**, you name your layout the way you want. 62 | 63 | **Layouts** are HTML files at the root of your `templates/` directory. They contain blocks that your views will fill. You may name them as you want but they must have a sub-directory in the `views` directory with their name without the `.html`. 64 | 65 | **views** directory contains one directory per layout file name without the .html extension. If you have three layout templates, `public.html`, `app.html`, and `xyz.html`, you'll have three sub-directories in the Views directory, each containing the views for this layout. So `views/public`, `views/app`, and `views/xyz`. 66 | 67 | **partials** is a directory where you put all re-usable pieces of template you need to embed into your HTML pages. For instance, you embed a `item-list.html` in 'views/xyz/list.html', `views/app/mylist.html`, and `views/xyz/admin-list.html` pages. All three can use the partial. 68 | 69 | __Note__: This directory was named `_partials` before, please rename it to `partials` to remove the obsolete warning. 70 | 71 | **emails** directory is used for your emails, those HTML templates does not have a base layout and are used as-is in terms of rendering. 72 | 73 | **translations** directory is where you put message translations via one file named after the language. It's optional, if you don't need translations you don't need to create this directory. 74 | 75 | ### Parsing and rendering 76 | 77 | You'll need to parse your templates at the start of your program. The library returns a `tpl.Template` structure that you use to render your pages and emails. 78 | 79 | For example: 80 | 81 | ```go 82 | package main 83 | 84 | import ( 85 | "embed" 86 | "net/http" 87 | "github.com/dstpierre/tpl" 88 | ) 89 | 90 | //go:embed templates 91 | var fs embed.FS 92 | 93 | func main() { 94 | // assuming your templates are in templates/ and have proper structure 95 | templ, err := tpl.Parse(fs, nil) 96 | // ... 97 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request)) { 98 | data := "this is your app's data you normally pass to Execute" 99 | if err := templ.Render(w, "app/dashboard.html", data); err != nil {} 100 | } 101 | } 102 | ) 103 | ``` 104 | 105 | __Note__: Previously it was required to wrap your template data into `tpl.PageData` structure. It's not required anymore, although `tpl` still exposes `PageData` you can use or embed into your own structure. 106 | 107 | For new project I tend to use `tpl.PageData` like this: 108 | 109 | ```go 110 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request)) { 111 | data := "this is your app's data you normally pass to Execute" 112 | pdata := tpl.PageData(Data: data, Lang: "fr") 113 | if err := templ.Render(w, "app/dashboard.html", pdata); err != nil {} 114 | } 115 | ``` 116 | 117 | And for existing project, I tend to embed `tpl.PageData` into my existing structure, so my HTML templates does not change much. 118 | 119 | ### PageData structure 120 | 121 | This structure is there if you need it. It just have a sane list of fields that most web application are using, you can use it or not, it's really up to you. 122 | 123 | Here's the fields of the `tpl.PageData`: 124 | 125 | ```go 126 | type PageData struct { 127 | Lang string 128 | Locale string 129 | Timezone string 130 | XSRFToken string 131 | Title string 132 | CurrentUser any 133 | Alert *Notification 134 | Data any 135 | Extra any 136 | Env string 137 | } 138 | ``` 139 | 140 | `Lang` and `Locale` are useful if you want to use the i18n feature. 141 | 142 | `CurrentUser` is handy if you want to let your templates know about the current user. 143 | 144 | `Env` is useful if your system has multiple environment, like dev, staging, prod and you'd want to do different things based on the env. I personally use if to have a non-minified JavaScript bundle in dev and staging, while a minified one in prod. 145 | 146 | `Extra` can be useful for anything that your views need that's not present in the main `Data` field. 147 | 148 | `Title` is also helpful to set the page title, you can have this in your layout templates: 149 | 150 | ```html 151 | {{.Title}} 152 | or 153 | {{if .Title}} 154 | {{.Title}} 155 | {{else}} 156 | Default title when empty 157 | {{end}} 158 | ``` 159 | 160 | `Alert` can be use to display flash message to the user, errors and successes etc. The `Notification` structure is this: 161 | 162 | ```go 163 | type Notification struct { 164 | Title template.HTML 165 | Message template.HTML 166 | IsSuccess bool 167 | IsError bool 168 | IsWarning bool 169 | } 170 | ``` 171 | 172 | Usually I have a `web` package with a `render.go` that handles and exposes a `Render` function, here's an example: 173 | 174 | ```go 175 | package web 176 | 177 | import ( 178 | "embed" 179 | "io" 180 | "log/slog" 181 | "net/http" 182 | 183 | "github.com/dstpierre/tpl" 184 | "github.com/dstpierre/xyz/data/model" 185 | "github.com/dstpierre/xyz/middleware" 186 | "golang.org/x/net/xsrftoken" 187 | ) 188 | 189 | //go:embed all:templates 190 | var fs embed.FS 191 | 192 | var templ *tpl.Template 193 | 194 | func LoadTemplates() error { 195 | t, err := tpl.Parse(fs, fmap()) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | templ = t 201 | 202 | return nil 203 | } 204 | 205 | type BackwardCompatPageData struct { 206 | tpl.PageData 207 | Role model.Roles 208 | Language string 209 | } 210 | 211 | func Render(w io.Writer, r *http.Request, view string, args ...any) { 212 | d := BackwardCompatPageData{} 213 | 214 | if len(args) > 0 { 215 | d.Data = args[0] 216 | } 217 | 218 | if len(args) >= 2 { 219 | n, ok := args[1].(*tpl.Notification) 220 | if ok { 221 | d.Alert = n 222 | } 223 | } 224 | 225 | if len(args) >= 3 { 226 | d.Extra = args[2] 227 | } 228 | 229 | s, ok := r.Context().Value(middleware.ContextKeySession).(model.Login) 230 | if ok { 231 | d.CurrentUser = &s 232 | 233 | d.Role = s.Role 234 | } 235 | 236 | d.XSRFToken = xsrftoken.Generate(XSRFToken, "", "") 237 | d.Locale = r.Context().Value(middleware.ContextKeyLanguage).(string) 238 | d.Lang = d.Locale[:2] 239 | 240 | d.Language = d.Lang 241 | 242 | if err := templ.Render(w, view, d); err != nil { 243 | slog.Error("error rendering page", "PAGE", view, "ERR", err) 244 | } 245 | } 246 | 247 | ``` 248 | 249 | This is a real-world example, I'm embedding `tpl.PageData` into an existing structure even if it's repeating some field as my existing HTML template were already using `{{ .Language }}` and `tpl.PageData` have a `Lang` field. 250 | 251 | > And yes, I was too lazy to replace all `.Language`. 252 | 253 | So it's really flexible if you either use it as-is or embed into an existing structure for existing HTML templates. 254 | 255 | I'm using it like this in an handler: 256 | 257 | ```go 258 | func x(w http.ResponseWriter, r *http.Request) { 259 | flash := &tpl.Notification{Message: "Did not work", isError: true} 260 | data := actionThatReturnAStruct() 261 | web.Render(w, r, "app/do.html", data, flash) 262 | } 263 | ``` 264 | 265 | The fact that my `web.Render` functions accept a variadic arguments I'm able to use the function somewhat relative to what happened in the handler. If there's no alert, I only pass the data, if there's no data, I just render the page. 266 | 267 | **This is just an **example, you can shape it the way you prefer. This library only facilitate the structuring, parsing, and rendering of templates. 268 | 269 | ## Example templates 270 | 271 | The tests use somewhat real-ish directories and file structures from which you may get started. 272 | 273 | Look at the `testdata` directory. In your program, you might want to name the root directory `templates` but it's configurable. 274 | 275 | ### Quick template example 276 | 277 | **templates/layout.html**: 278 | 279 | ```html 280 | {{template "nav.html"}} 281 | 282 |
{{block "content" .}}{{end}}
283 | ``` 284 | 285 | **templates/views/layout/home.html**: 286 | 287 | ```html 288 | {{define "content"}} 289 |

From the home.html view

290 | {{end}} 291 | ``` 292 | 293 | **templates/_partials/nav.html**: 294 | 295 | ```html 296 | 299 | ``` 300 | 301 | ## Rendering emails 302 | 303 | There's nothing really special regarding emails, other than `tpl` handles their rendering directly, once you have call the `Parse` function you may render any email template like so: 304 | 305 | ```go 306 | func sendVerifyEmail(token string) error { 307 | type EmailData struct { 308 | Link string 309 | } 310 | 311 | data := EmailData{Link: "https://verify.com/" + token} 312 | 313 | var buf bytes.Buffer 314 | if err := templ.RenderEmail(&buf, "verify-email.txt", data); err != nil { 315 | return err 316 | } 317 | 318 | // you can now send the email and use the bytes as the body 319 | } 320 | ``` 321 | 322 | Your templates in `templates/emails` can access all built-in functions and will also have the same funcmap as your HTML templates. 323 | 324 | ## i18n 325 | 326 | If your web application needs multilingual support, you can create language message files and save them in the Translations directory. 327 | 328 | **templates/translations/en.json**: 329 | 330 | ```json 331 | [{ 332 | "key": "unique key", 333 | "value": "The value", 334 | "plural": "Optional value for plural", 335 | }] 336 | ``` 337 | 338 | The translation functions expect a language as first argument. This is where the `tpl.PageData` may come handy if you use it directly or embed it in your structure. 339 | 340 | ```go 341 | func home(w http.ResponseWriter, r *http.Request) { 342 | pdata := tpl.PageData{Lang: "fr", Data: 1234} 343 | if err := templ.Render(w, "layout/home.html", pdata); err != nil {} 344 | } 345 | ``` 346 | 347 | Inside your templates: 348 | 349 | ```html 350 |

{{ 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. --------------------------------------------------------------------------------