-
53 |
- Name 54 |
- John Doe 55 | 56 |
- Address 57 |
- Mainstreet 1st 58 | 59 |
- Subscription 60 |
- Premium 61 |
├── LICENSE.txt ├── README.md ├── examples ├── README.md ├── main.go └── templates │ ├── includes │ ├── footer.html │ └── header.html │ ├── layout.html │ └── profile │ ├── edit.html │ ├── layout.html │ ├── payment │ ├── includes │ │ └── method.html │ ├── layout.html │ └── methods.html │ └── view.html ├── go.mod ├── go.sum ├── templatex.go ├── templatex_test.go └── test └── templates ├── fs.go ├── includes ├── footer.html └── header.html ├── layout.html └── profile ├── edit.html ├── layout.html ├── payments ├── includes │ └── method.html ├── layout.html └── methods.html └── view.html /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Philipp Tanlak 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # templatex 2 | 3 | The missing function for parsing nested templates in Go, like you know it from Laravel, Django, etc. 4 | * Use the whole feature set of Go's templating library 5 | * No new syntax or keywords. Just Go's templating functions in the right order 6 | * No dependencies, just Go's standard library 7 | * Super easy to use 8 | 9 | Your template structure can now look like this and parsing will be done for you: 10 | 11 | ``` 12 | templates/ 13 | layout.html 14 | 15 | includes/ 16 | header.html 17 | footer.html 18 | 19 | dashboard/ 20 | includes/ 21 | widgets.html 22 | view.html 23 | edit.html 24 | 25 | profile/ 26 | view.html 27 | edit.html 28 | 29 | payments/ 30 | methods.html 31 | add.html 32 | ``` 33 | 34 | ## Installation 35 | 36 | ``` 37 | go get -u github.com/philippta/templatex 38 | ``` 39 | 40 | ## Usage 41 | 42 | templatex is very easy to use as it only has one type and three methods. 43 | The most basic way to use it is with default options. 44 | This will parse the `templates/` dire 45 | 46 | ```go 47 | t := templatex.New() 48 | 49 | t.ParseDir("templates/") 50 | 51 | t.ExecuteTemplate(w, "profile/view", userdata) 52 | ``` 53 | 54 | The parser also has options to use different names for the includes directory and layout files. Additional template functions can also be supplied: 55 | 56 | ```go 57 | t := &templatex.Template{ 58 | Layout: "layout.html", 59 | IncludeDir: "includes", 60 | FuncMap: template.FuncMap{ 61 | "uppercase": strings.ToUpper, 62 | }, 63 | } 64 | ``` 65 | 66 | ## Parsing order 67 | 68 | Based on the example from above: 69 | ``` 70 | templates/ 71 | layout.html 72 | 73 | includes/ 74 | header.html 75 | footer.html 76 | 77 | dashboard/ 78 | includes/ 79 | widgets.html 80 | view.html 81 | edit.html 82 | 83 | profile/ 84 | view.html 85 | edit.html 86 | 87 | payments/ 88 | methods.html 89 | add.html 90 | ``` 91 | 92 | templatex will parse the templates in the following order: 93 | ``` 94 | dashboard/view: 95 | templates/includes/header.html 96 | templates/includes/footer.html 97 | templates/dashboard/includes/widgets.html 98 | templates/layout.html 99 | templates/dashboard/view.html 100 | 101 | dashboard/edit: 102 | templates/includes/header.html 103 | templates/includes/footer.html 104 | templates/dashboard/includes/widgets.html 105 | templates/layout.html 106 | templates/dashboard/edit.html 107 | 108 | profile/view: 109 | templates/includes/header.html 110 | templates/includes/footer.html 111 | templates/layout.html 112 | templates/profile/view.html 113 | 114 | profile/edit: 115 | templates/includes/header.html 116 | templates/includes/footer.html 117 | templates/layout.html 118 | templates/profile/edit.html 119 | 120 | profile/payments/methods: 121 | templates/includes/header.html 122 | templates/includes/footer.html 123 | templates/layout.html 124 | templates/profile/payments/methods.html 125 | 126 | profile/payments/add: 127 | templates/includes/header.html 128 | templates/includes/footer.html 129 | templates/layout.html 130 | templates/profile/payments/add.html 131 | ``` 132 | 133 | ## Defining nested templates 134 | 135 | Defining nested templates in Go is relatively simple. There are just three instructions you should keep in mind: 136 | 1. `block` for creating an new block 137 | 2. `define` for filling a block 138 | 3. `template` for rendering a partial/include template 139 | 140 | ### Step 1: Creating a block 141 | Creating a block is the essential part for nested templates, as these parts can be later filled by a child template. It is created by the `block` command. 142 | ```html 143 | 144 |
145 |151 | This renders, when the block has not been filled. 152 | Otherwise this message is discarded. 153 | Good for default content. 154 |
155 | {{end}} 156 | 157 | ``` 158 | 159 | ### Step 2: Filling a block 160 | Filling a block which was created in a parent template is done with the `define` command. So basically, we redefine the contents of a block. 161 | ```html 162 | 163 | {{define "title"}} 164 | You're on profile/view 165 | {{end}} 166 | 167 | {{define "content"}} 168 |169 | This will render inside the "content" block. 170 | There is no need to place any html outside of this block 171 |
172 | {{end}} 173 | ``` 174 | 175 | ### Step 3: Using partial/include templates 176 | Creating an include template is similar to step 2. Here we're not redefining a existing block. We rather define a new block which is not rendered immediately. 177 | ```html 178 | 179 | {{define "footer"}} 180 | © Company 2020. All rights reserved 181 | {{end}} 182 | ``` 183 | 184 | Then render it like this with the `template` command: 185 | ```html 186 | 187 | ... 188 | {{template "footer" .}} 189 | 47 | 48 |190 | ``` 191 | 192 | 193 | ## Example 194 | 195 | Make sure to checkout the [example](examples/README.md) in this repository. 196 | 197 | ## License 198 | MIT 199 | 200 | 201 | ## Enjoy :-) 202 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | This example demonstrates how to use the `github.com/philippta/templatex` package. 4 | 5 | ## Code 6 | 7 | Create a new `templatex.Template` with custom parameters: 8 | ```go 9 | tmpl := &templatex.Template{ 10 | // Use "layout.html" as base layout name 11 | Layout: "layout.html", 12 | 13 | // Use "includes" as directory for partial templates 14 | IncludeDir: "includes", 15 | 16 | // Add strings.ToUpper as a template function 17 | FuncMap: template.FuncMap{ 18 | "uppercase": strings.ToUpper, 19 | }, 20 | } 21 | ``` 22 | 23 | Parse the `templates/` directory: 24 | ```go 25 | tmpl.ParseDir("templates/") 26 | ``` 27 | 28 | Execute the templates: 29 | ```go 30 | tmpl.ExecuteTemplate(os.Stdout, "profile/view", viewParams) 31 | tmpl.ExecuteTemplate(os.Stdout, "profile/edit", editParams) 32 | tmpl.ExecuteTemplate(os.Stdout, "profile/payment/methods", paymentMethodsParams) 33 | ``` 34 | 35 | ## Result 36 | 37 | The result of these three demo pages will look like this (whitespaces have been cleaned up): 38 | 39 | ### profile/view 40 | ```html 41 | 42 |
43 |
44 | 45 | 46 |
70 | ``` 71 | 72 | ### profile/edit 73 | ```html 74 | 75 |
76 |
77 | 78 | 79 |
107 | ``` 108 | 109 | ### profile/payment/methods 110 | ```html 111 | 112 |
113 |
114 | 115 | 116 |
154 | ``` 155 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/philippta/templatex" 10 | ) 11 | 12 | type User struct { 13 | Name string 14 | Address string 15 | Subscription string 16 | } 17 | 18 | type PaymentMethod struct { 19 | ID string 20 | Title string 21 | } 22 | 23 | type ViewTemplateParams struct { 24 | Title string 25 | User User 26 | } 27 | 28 | type EditTemplateParams struct { 29 | Title string 30 | User User 31 | } 32 | 33 | type PaymentMethodsTemplateParams struct { 34 | Title string 35 | PaymentMethods []PaymentMethod 36 | } 37 | 38 | func main() { 39 | // For the default parser without any template functions or custom layout / include names: 40 | // tmpl := templatex.New() 41 | 42 | tmpl := &templatex.Template{ 43 | Layout: "layout.html", 44 | IncludeDir: "includes", 45 | FuncMap: template.FuncMap{ 46 | "uppercase": strings.ToUpper, 47 | }, 48 | } 49 | 50 | var err error 51 | 52 | // Parse templates/ directory 53 | err = tmpl.ParseDir("templates/") 54 | check(err) 55 | 56 | // Execute profile/view template to stdout 57 | viewParams := ViewTemplateParams{ 58 | Title: "Profile", 59 | User: User{ 60 | Name: "John Doe", 61 | Address: "Mainstreet 1st", 62 | Subscription: "Premium", 63 | }, 64 | } 65 | err = tmpl.ExecuteTemplate(os.Stdout, "profile/view", viewParams) 66 | check(err) 67 | 68 | // Execute profile/edit template to stdout 69 | editParams := EditTemplateParams{ 70 | Title: "Profile Edit", 71 | User: User{ 72 | Name: "John Doe", 73 | Address: "Mainstreet 1st", 74 | Subscription: "Premium", 75 | }, 76 | } 77 | err = tmpl.ExecuteTemplate(os.Stdout, "profile/edit", editParams) 78 | check(err) 79 | 80 | // Execute profile/payment/methods template to stdout 81 | paymentMethodsParams := PaymentMethodsTemplateParams{ 82 | Title: "Payment methods", 83 | PaymentMethods: []PaymentMethod{ 84 | { 85 | ID: "debit", 86 | Title: "Debit card", 87 | }, 88 | { 89 | ID: "cc", 90 | Title: "Credit card", 91 | }, 92 | { 93 | ID: "paypal", 94 | Title: "Paypal", 95 | }, 96 | }, 97 | } 98 | err = tmpl.ExecuteTemplate(os.Stdout, "profile/payment/methods", paymentMethodsParams) 99 | check(err) 100 | } 101 | 102 | func check(err error) { 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /examples/templates/includes/footer.html: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 |
5 | {{end}} 6 | -------------------------------------------------------------------------------- /examples/templates/includes/header.html: -------------------------------------------------------------------------------- 1 | {{define "header"}} 2 |
3 | {{end}} 4 | -------------------------------------------------------------------------------- /examples/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | 7 |
18 | -------------------------------------------------------------------------------- /examples/templates/profile/edit.html: -------------------------------------------------------------------------------- 1 | {{define "profile"}} 2 |
15 | {{end}} 16 | -------------------------------------------------------------------------------- /examples/templates/profile/layout.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
7 | {{end}} 8 | -------------------------------------------------------------------------------- /examples/templates/profile/payment/includes/method.html: -------------------------------------------------------------------------------- 1 | {{define "method"}} 2 |
5 | {{end}} 6 | -------------------------------------------------------------------------------- /examples/templates/profile/payment/layout.html: -------------------------------------------------------------------------------- 1 | {{define "profile"}} 2 |
7 | {{end}} 8 | -------------------------------------------------------------------------------- /examples/templates/profile/payment/methods.html: -------------------------------------------------------------------------------- 1 | {{define "payments"}} 2 |
3 | {{range .PaymentMethods}} 4 |
7 | {{end}} 8 | {{end}} 9 | -------------------------------------------------------------------------------- /examples/templates/profile/view.html: -------------------------------------------------------------------------------- 1 | {{define "profile"}} 2 |
14 | {{end}} 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/philippta/templatex 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philippta/templatex/42f8c65a362ddebcdce671395a6a5714a0a07c2b/go.sum -------------------------------------------------------------------------------- /templatex.go: -------------------------------------------------------------------------------- 1 | package templatex 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | // ParseError represents an error which can occure when trying to parse a template 15 | type ParseError struct { 16 | Path string 17 | Err error 18 | } 19 | 20 | func (e ParseError) Error() string { 21 | return fmt.Sprintf(`error while parsing "%v": %v`, e.Path, e.Err) 22 | } 23 | 24 | func (e ParseError) Unwrap() error { 25 | return e.Err 26 | } 27 | 28 | // NotFound represents an error which can occure when trying to execute a template, 29 | // which does not exist 30 | type NotFoundError struct { 31 | Template string 32 | } 33 | 34 | func (e NotFoundError) Error() string { 35 | return "template not found: " + e.Template 36 | } 37 | 38 | // ExecuteError represents an error which can occure while trying to execute a template 39 | type ExecuteError struct { 40 | Template string 41 | Err error 42 | } 43 | 44 | func (e ExecuteError) Error() string { 45 | return fmt.Sprintf(`error executing template "%v": %v`, e.Template, e.Err) 46 | } 47 | 48 | func (e ExecuteError) Unwrap() error { 49 | return e.Err 50 | } 51 | 52 | // New creates a new Template with sane default values for directories like: 53 | // templates/ 54 | // layout.html 55 | // 56 | // includes/ 57 | // header.html 58 | // footer.html 59 | // 60 | // profile/ 61 | // view.html 62 | // edit.html 63 | func New() *Template { 64 | return &Template{ 65 | Layout: "layout.html", 66 | IncludeDir: "includes", 67 | } 68 | } 69 | 70 | // Template represents a container for multiple templates parsed from a directory 71 | type Template struct { 72 | // Layout specifies the filename of the layout files in a directory 73 | // Most commonly: "layout.html" or "base.html" 74 | Layout string 75 | 76 | // IncludeDir specifies the directory name where partial templates can be found 77 | // Most commonly: "includes", "include" or "inc" 78 | IncludeDir string 79 | 80 | // FuncMap is a map of functions, given to the templates while parsing 81 | FuncMap template.FuncMap 82 | 83 | // templates is a map of template identifiers to executable templates 84 | templates map[string]*template.Template 85 | } 86 | 87 | // ParseDir parses all templates inside a given directory 88 | func (t *Template) ParseDir(dir string) (err error) { 89 | t.templates, err = parseFS(os.DirFS("."), dir, t.IncludeDir, t.Layout, t.FuncMap) 90 | return 91 | } 92 | 93 | // ParseFS parses all templates inside a given fs.FS 94 | func (t *Template) ParseFS(files fs.FS, dir string) (err error) { 95 | t.templates, err = parseFS(files, dir, t.IncludeDir, t.Layout, t.FuncMap) 96 | return 97 | } 98 | 99 | // ExecuteTemplate executes a template by its name to a io.Writer with any given data 100 | func (t *Template) ExecuteTemplate(w io.Writer, name string, data interface{}) error { 101 | tmpl, ok := t.templates[name] 102 | if !ok { 103 | return NotFoundError{Template: name} 104 | } 105 | if err := tmpl.Execute(w, data); err != nil { 106 | return ExecuteError{Template: name, Err: err} 107 | } 108 | return nil 109 | } 110 | 111 | // parseDir builds templates inside a given directory 112 | func parseFS(files fs.FS, dir, includeDir, layout string, funcMap template.FuncMap) (map[string]*template.Template, error) { 113 | templates := map[string]*template.Template{} 114 | 115 | // Collect template parsing information of the given directory 116 | templateInfos, err := findTemplates(files, dir, includeDir, layout) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | for _, info := range templateInfos { 122 | var err error 123 | 124 | // Create a new empty layout with the name of the layout file 125 | t := template.New(layout).Funcs(funcMap) 126 | 127 | // Use ParseGlob to parse all partial templates from the include directories 128 | for _, f := range info.includes { 129 | gf, err := fs.Glob(files, f+string(filepath.Separator)+"*") 130 | if err != nil { 131 | return nil, ParseError{Path: f + string(filepath.Separator) + "*", Err: err} 132 | } 133 | t, err = t.ParseFS(files, gf...) 134 | if err != nil { 135 | return nil, ParseError{Path: f + string(filepath.Separator) + "*", Err: err} 136 | } 137 | } 138 | 139 | // Parse the rest of the templates 140 | t, err = t.ParseFS(files, info.files...) 141 | if err != nil { 142 | return nil, ParseError{Path: fmt.Sprintf("%v", info.files), Err: err} 143 | } 144 | 145 | // Add the parsed template to the template map 146 | templates[info.id] = t 147 | } 148 | 149 | return templates, nil 150 | } 151 | 152 | // templateInfo contains all template information neccessary to parse a 153 | // final template with its dependencies (layout templates, include templates) 154 | // It also contains an identifier for the resulting template to execute 155 | type templateInfo struct { 156 | id string 157 | includes []string 158 | files []string 159 | } 160 | 161 | // fileTemplates returns a list of all executable templates 162 | // with their respective layout dependencies and include templates 163 | func findTemplates(files fs.FS, dir, includeDir, layout string) ([]templateInfo, error) { 164 | // Cleans trailing slashs from directories 165 | dir = filepath.Clean(dir) 166 | includeDir = filepath.Clean(includeDir) 167 | 168 | // Slices to hold all found files and directories 169 | includeDirs := []string{} 170 | layouts := []string{} 171 | templates := []string{} 172 | 173 | // walkfn finds all files and directories inside of dir 174 | walkfn := func(path string, info fs.DirEntry, err error) error { 175 | if err != nil { 176 | return err 177 | } 178 | 179 | if info.IsDir() { 180 | // Check if the found directory is an include directory 181 | if filepath.Base(path) == includeDir { 182 | includeDirs = append(includeDirs, path) 183 | } 184 | return nil 185 | } 186 | 187 | // Skip all templates found in an include directory 188 | if filepath.Base(filepath.Dir(path)) == includeDir { 189 | return nil 190 | } 191 | 192 | // Determine if the template is a base/layout template or normal template 193 | if strings.HasSuffix(path, string(filepath.Separator)+layout) || path == layout { 194 | layouts = append(layouts, path) 195 | } else { 196 | templates = append(templates, path) 197 | } 198 | 199 | return nil 200 | } 201 | if err := fs.WalkDir(files, dir, walkfn); err != nil { 202 | return nil, ParseError{Path: dir, Err: err} 203 | } 204 | 205 | // Sort all base/layout templates by their directory depth (shallow to deep) 206 | sort.Slice(layouts, func(i, j int) bool { 207 | return len(layouts[i]) < len(layouts[j]) 208 | }) 209 | 210 | // Sort all include directories by their directory depth (shallow to deep) 211 | sort.Slice(includeDirs, func(i, j int) bool { 212 | return len(includeDirs[i]) < len(includeDirs[j]) 213 | }) 214 | 215 | // For each found normal template, build a list of dependencies to parse 216 | templateInfos := []templateInfo{} 217 | for _, t := range templates { 218 | files := []string{} 219 | includes := []string{} 220 | 221 | // Add all include directories which lie in the same directory hirachy 222 | for _, i := range includeDirs { 223 | if strings.HasPrefix(t, filepath.Dir(i)) || filepath.Dir(i) == "." { 224 | includes = append(includes, i) 225 | } 226 | } 227 | 228 | // Add all base/layout templates which lie in the same directory hirachy 229 | for _, l := range layouts { 230 | if strings.HasPrefix(t, filepath.Dir(l)) || filepath.Dir(l) == "." { 231 | files = append(files, l) 232 | } 233 | } 234 | 235 | // Add the final template as the last entry 236 | files = append(files, t) 237 | 238 | // Build the template identifier based on the path of the final template 239 | // e.g.