├── .github └── workflows │ └── verify.yml ├── .gitignore ├── .idea ├── .gitignore ├── modules.xml ├── tmpl.iml └── vcs.xml ├── LICENSE ├── README.md ├── analyze.go ├── analyzers.go ├── cmd └── tmpl │ ├── Taskfile.yml │ ├── cmd │ ├── bind.go │ ├── root.go │ └── templates │ │ ├── _tmpl.tmpl │ │ ├── fileprovider.tmpl │ │ └── textprovider.tmpl │ └── tmpl.go ├── compile.go ├── compile_test.go ├── funcmap.go ├── funcmap_test.go ├── go.mod ├── go.sum ├── reflect.go ├── reflect_test.go ├── render.go ├── template.go ├── testdata ├── compiler_test.tmpl.html ├── structs.go └── templates │ └── watch_test.tmpl.html └── traverse.go /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: tmpl 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: '1.20' 16 | 17 | - name: Install 18 | run: go install ./cmd/tmpl/tmpl.go 19 | 20 | - name: Build 21 | run: go generate -v ./... 22 | 23 | - name: Test 24 | run: go test -v ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | *_gen_test.go -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/tmpl.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tyler 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 | # `tmpl` 2 | 3 | `tmpl` is a developer-friendly wrapper around Go's `html/template` package, designed to simplify common tasks, enhance type safety, and make complex template setups more maintainable and readable. If you've ever felt frustration dealing with loosely-coupled templates and Go code, `tmpl` was built specifically for you. 4 | 5 | This project attempts to improve the overall template workflow and offers a few helpful utilities for developers building html based applications: 6 | 7 | - Two-way type safety when referencing templates in Go code and vice-versa 8 | - Nested templates and template fragments 9 | - Template extensibility through compiler plugins 10 | - Static analysis utilities such as template parse tree traversal 11 | 12 | *Roadmap & Idea List* 13 | 14 | - Parsing and static analysis of the html in a template 15 | - Automatic generation of [GoLand `{{ gotype: }}` annotations](https://www.jetbrains.com/help/go/integration-with-go-templates.html) when using the `tmpl` CLI 16 | - Documentation on how to use `tmpl.Analyze` for parse tree traversal and static analysis of templates 17 | 18 | ## 🧰 Installation 19 | ```bash 20 | go get github.com/tylermmorton/tmpl 21 | ``` 22 | 23 | ## 🌊 The Workflow 24 | 25 | The `tmpl` workflow starts with a standard `html/template`. For more information on the syntax, see this [useful syntax primer from HashiCorp](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax). 26 | 27 | ```html 28 | 29 | 30 | 31 | 32 | {{ .Title }} | torque 33 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | 45 | ``` 46 | 47 | ### Dot Context 48 | 49 | To start tying your template to your Go code, declare a struct that represents the "dot context" of the template. The dot context is the value of the "dot" (`{{ . }}`) in Go's templating language. 50 | 51 | In this struct, any _exported_ fields (or methods attached via pointer receiver) will be accessible in your template from the all powerful dot. 52 | 53 | ```go 54 | type LoginPage struct { 55 | Title string // {{ .Title }} 56 | Username string // {{ .Username }} 57 | Password string // {{ .Password }} 58 | } 59 | ``` 60 | 61 | ### `TemplateProvider` 62 | 63 | To turn your dot context struct into a target for the tmpl compiler, your struct type must implement the `TemplateProvider` interface: 64 | 65 | ```go 66 | type TemplateProvider interface { 67 | TemplateText() string 68 | } 69 | ``` 70 | 71 | The most straightforward approach is to embed the template into your Go program using the `embed` package from the standard library. 72 | 73 | ```go 74 | import ( 75 | _ "embed" 76 | ) 77 | 78 | var ( 79 | //go:embed login.tmpl.html 80 | tmplLoginPage string 81 | ) 82 | 83 | type LoginPage struct { 84 | ... 85 | } 86 | 87 | func (*LoginPage) TemplateText() string { 88 | return tmplLoginPage 89 | } 90 | ``` 91 | 92 | ### Compilation 93 | 94 | After implementing `TemplateProvider` you're ready to compile your template and use it in your application. 95 | 96 | Currently, it is recommended to compile your template once at program startup using the function `tmpl.MustCompile`: 97 | 98 | ```go 99 | var ( 100 | LoginTemplate = tmpl.MustCompile(&LoginPage{}) 101 | ) 102 | ``` 103 | 104 | If any of your template's syntax were to be invalid, the compiler will `panic` on application startup with a detailed error message. 105 | 106 | > If you prefer to avoid panics and handle the error yourself, use the `tmpl.Compile` function variant. 107 | 108 | The compiler returns a managed `tmpl.Template` instance. These templates are safe to use from multiple Go routines. 109 | 110 | ### Rendering 111 | 112 | After compilation, you may execute your template by calling one of the generic render functions. 113 | 114 | ```go 115 | type Template[T TemplateProvider] interface { 116 | Render(w io.Writer, data T, opts ...RenderOption) error 117 | RenderToChan(ch chan string, data T, opts ...RenderOption) error 118 | RenderToString(data T, opts ...RenderOption) (string, error) 119 | } 120 | ``` 121 | 122 | ```go 123 | var ( 124 | LoginTemplate = tmpl.MustCompile(&LoginPage{}) 125 | ) 126 | 127 | func main() { 128 | buf := bytes.Buffer{} 129 | err := LoginTemplate.Render(&buf, &LoginPage{ 130 | Title: "Login", 131 | Username: "", 132 | Password: "", 133 | }) 134 | if err != nil { 135 | panic(err) 136 | } 137 | 138 | fmt.Println(buf.String()) 139 | } 140 | ``` 141 | 142 | ### Template Functions 143 | 144 | `tmpl` supports multiple ways of providing functions to your templates. 145 | 146 | #### Dot Context Methods 147 | 148 | You can define methods on your dot context struct to be used as template functions. These methods must be attached to your struct via pointer receiver. This strategy is useful if your template function depends on a lot of internal state. 149 | 150 | ```go 151 | type LoginPage struct { 152 | FirstName string 153 | LastName string 154 | } 155 | 156 | func (p *LoginPage) FullName() string { 157 | return fmt.Sprintf("%s %s", p.FirstName, p.LastName) 158 | } 159 | ``` 160 | 161 | ```html 162 | {{ .FullName }} 163 | ``` 164 | 165 | #### `FuncMapProvider` 166 | 167 | You can also define template functions on the dot context struct by implementing the `FuncMapProvider` interface. This is useful for reusing utility functions across multiple templates and packages. 168 | 169 | ```go 170 | package tmpl 171 | 172 | type FuncMapProvider interface { 173 | TemplateFuncMap() FuncMap 174 | } 175 | ``` 176 | 177 | Example using the [sprig](https://github.com/Masterminds/sprig) library: 178 | ```go 179 | import ( 180 | "github.com/Masterminds/sprig/v3" 181 | ) 182 | 183 | type LoginPage struct { 184 | ... 185 | } 186 | 187 | func (*LoginPage) TemplateFuncMap() tmpl.FuncMap { 188 | return sprig.FuncMap() 189 | } 190 | ``` 191 | 192 | Usage: 193 | ```html 194 | {{ "hello!" | upper | repeat 5 }} 195 | ``` 196 | 197 | ### Template Nesting 198 | 199 | One major advantage of using structs to bind templates is that nesting templates is as easy as nesting structs. 200 | 201 | The tmpl compiler knows to recursively look for fields in your dot context struct that also implement the `TemplateProvider` interface. This includes fields that are embedded, slices or pointers. 202 | 203 | A good use case for nesting templates is to abstract the document `` of the page into a separate template that can now be shared and reused by other pages: 204 | 205 | ```html 206 | 207 | 208 | {{ .Title }} | torque 209 | 210 | {{ range .Scripts -}} 211 | 212 | {{ end -}} 213 | 214 | ``` 215 | 216 | ```go 217 | type Head struct { 218 | Title string 219 | Scripts []string 220 | } 221 | ``` 222 | 223 | Now, update the `LoginPage` struct to embed the new `Head` template. 224 | 225 | The name of the template is defined using the `tmpl` struct tag. If the tag is not present the field name is used instead. 226 | 227 | ```go 228 | type LoginPage struct { 229 | Head `tmpl:"head"` 230 | 231 | Username string 232 | Password string 233 | } 234 | ``` 235 | 236 | Embedded templates can be referenced using the built in `{{ template }}` directive. Use the name assigned in the struct tag and ensure to pass the dot context value. 237 | 238 | ```html 239 | 240 | 241 | {{ template "head" .Head }} 242 | 243 | ... 244 | 245 | 246 | ``` 247 | 248 | Finally, update references to `LoginPage` to include the nested template's dot as well. 249 | 250 | ```go 251 | var ( 252 | LoginTemplate = tmpl.MustCompile(&LoginPage{}) 253 | ) 254 | 255 | func main() { 256 | buf := bytes.Buffer{} 257 | err := LoginTemplate.Render(&buf, &LoginPage{ 258 | Head: &Head{ 259 | Title: "Login", 260 | Scripts: []string{ "https://unpkg.com/htmx.org@1.9.2" }, 261 | }, 262 | Username: "", 263 | Password: "", 264 | }) 265 | if err != nil { 266 | panic(err) 267 | } 268 | 269 | fmt.Println(buf.String()) 270 | } 271 | ``` 272 | 273 | ### Targeting 274 | 275 | Sometimes you may want to render a nested template. To do this, use the `RenderOption` `WithTarget` in any of the render functions: 276 | 277 | ```go 278 | func main() { 279 | buf := bytes.Buffer{} 280 | err := LoginTemplate.Render(&buf, &LoginPage{ 281 | Title: "Login", 282 | Username: "", 283 | Password: "", 284 | }, tmpl.WithTarget("head")) 285 | if err != nil { 286 | panic(err) 287 | } 288 | } 289 | ``` 290 | 291 | ## Advanced Usage 292 | 293 | ### Template Analysis 294 | 295 | The `tmpl` package provides a static analysis tool for Go templates. This tool can be used to traverse the parse tree of a template and perform custom analysis. The analysis framework is what enables the `tmpl` compiler to perform static analysis on your templates and provide type safety. 296 | 297 | 298 | ### `Analyzer` 299 | 300 | An `Analyzer` is a function that returns an `AnalyzerFunc`, which is a visitor-style function that allows you to traverse the parse tree of a template. `Analyzer`s can be provided to `tmpl.Compile` using the `UseAnalyzers` option. 301 | 302 | In the following example, we search templates for instances of `{{ outlet }}` and dynamically inject a function. This is how the `torque` framework uses the `tmpl` compiler to provide handler wrapping functionality. [Example](https://github.com/tylermmorton/torque/blob/master/template.go) 303 | 304 | You may want to do something similar if you want to add new 'built in' directives and functions to your templates. 305 | 306 | ```go 307 | package main 308 | 309 | var outletAnalyzer tmpl.Analyzer = func(h *tmpl.AnalysisHelper) tmpl.AnalyzerFunc { 310 | return tmpl.AnalyzerFunc(func(val reflect.Value, node parse.Node) { 311 | switch node := node.(type) { 312 | case *parse.IdentifierNode: 313 | if node.Ident == "outlet" { 314 | h.AddFunc("outlet", func() string { return "{{ . }}" }) 315 | } 316 | } 317 | }) 318 | } 319 | 320 | var LoginPage = tmpl.MustCompile(&LoginPage{}, tmpl.UseAnalyzers(outletAnalyzer)) 321 | ``` 322 | 323 | ### `AnalysisHelper` 324 | 325 | The `AnalysisHelper` allows you to modify the template during analysis. It provides methods to add functions, variables, and other nodes to the template. This is useful for modifying the template during analysis without having to modify the original template. 326 | 327 | ### `Analyze` 328 | 329 | The `Analyze` function can be used independently of the `Compile` function and allows you to analyze templates without compiling them. This is useful for static analysis and debugging purposes. 330 | 331 | ```go 332 | package main 333 | 334 | import ( 335 | "fmt" 336 | "html/template" 337 | 338 | "github.com/tylermmorton/tmpl" 339 | ) 340 | 341 | func main() { 342 | tmpl.Analyze(&LoginPage{}, tmpl.ParseOptions{}, []tmpl.Analyzer{ ... }) 343 | } 344 | ``` 345 | -------------------------------------------------------------------------------- /analyze.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "reflect" 9 | "strings" 10 | "text/template/parse" 11 | ) 12 | 13 | type FuncMap = template.FuncMap 14 | 15 | // AnalysisHelper is a struct that contains all the data collected 16 | // during an analysis of a TemplateProvider. 17 | // 18 | // An Analysis runs in two passes. The first pass collects important 19 | // contextual information about the template definition tree that can 20 | // be accessed in the second pass. The second pass is the actual analysis 21 | // of the template definition tree where errors and warnings are added. 22 | type AnalysisHelper struct { 23 | ctx context.Context 24 | //pre-analysis data 25 | // treeSet is a map of all templates defined in the TemplateProvider, 26 | // as well as all of its children. 27 | treeSet map[string]*parse.Tree 28 | // fieldTree is a tree structure of all struct fields in the TemplateProvider, 29 | // as well as all of its children. 30 | fieldTree *FieldNode 31 | 32 | //analysis data 33 | // errors is a slice of Errors that occurred during analysis. 34 | errors []string 35 | // warnings is a slice of Warnings that occurred during analysis. 36 | warnings []string 37 | // funcMap is a map of functions provided by analyzers that should 38 | // be added before the template is executed. 39 | funcMap template.FuncMap 40 | 41 | // TODO: what if... 42 | // Fixers []FixerFn 43 | } 44 | 45 | // IsDefinedTemplate returns true if the given template name is defined in the 46 | // analysis target via {{define}}, or defined by any of its embedded templates. 47 | func (h *AnalysisHelper) IsDefinedTemplate(name string) bool { 48 | if name == "outlet" { 49 | return true 50 | } 51 | 52 | _, ok := h.treeSet[name] 53 | return ok 54 | } 55 | 56 | func (h *AnalysisHelper) GetDefinedField(name string) *FieldNode { 57 | name = strings.TrimPrefix(name, ".") 58 | if len(name) == 0 { 59 | return h.fieldTree 60 | } 61 | return h.fieldTree.FindPath(strings.Split(name, ".")) 62 | } 63 | 64 | func (h *AnalysisHelper) FuncMap() FuncMap { 65 | return h.funcMap 66 | } 67 | 68 | func (h *AnalysisHelper) AddError(node parse.Node, err string) { 69 | // TODO: to get a useful error message, convert byte position (offset) to line numbers 70 | h.errors = append(h.errors, fmt.Sprintf("%v: %s", node.Position(), err)) 71 | } 72 | 73 | func (h *AnalysisHelper) AddWarning(node parse.Node, err string) { 74 | h.warnings = append(h.warnings, fmt.Sprintf("%v: %s", node.Position(), err)) 75 | } 76 | 77 | func (h *AnalysisHelper) AddFunc(name string, fn interface{}) { 78 | if h.funcMap == nil { 79 | h.funcMap = make(FuncMap) 80 | } 81 | h.funcMap[name] = fn 82 | } 83 | 84 | func (h *AnalysisHelper) Context() context.Context { 85 | return h.ctx 86 | } 87 | 88 | func (h *AnalysisHelper) WithContext(ctx context.Context) { 89 | h.ctx = ctx 90 | } 91 | 92 | // ParseOptions controls the behavior of the templateProvider parser used by Analyze. 93 | type ParseOptions struct { 94 | Funcs FuncMap 95 | LeftDelim string 96 | RightDelim string 97 | } 98 | 99 | type AnalyzerFunc func(val reflect.Value, node parse.Node) 100 | 101 | // Analyzer is a type that parses templateProvider text and performs an analysis 102 | type Analyzer func(res *AnalysisHelper) AnalyzerFunc 103 | 104 | // Analyze uses reflection on the given TemplateProvider while also parsing the 105 | // templateProvider text to perform an analysis. The analysis is performed by the given 106 | // analyzers. The analysis is returned as an AnalysisHelper struct. 107 | func Analyze(tp TemplateProvider, opts ParseOptions, analyzers []Analyzer) (*AnalysisHelper, error) { 108 | helper, err := createHelper(tp, opts) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | pt := helper.treeSet[strings.TrimPrefix(fmt.Sprintf("%T", tp), "*")] 114 | val := reflect.ValueOf(tp) 115 | 116 | // Do the actual traversal and analysis of the given template provider 117 | Traverse(pt.Root, Visitor(func(node parse.Node) { 118 | for _, fn := range analyzers { 119 | fn(helper)(val, node) 120 | } 121 | })) 122 | 123 | // During runtime compilation we're only worried about errors 124 | // During static analysis we're worried about errors but also 125 | // return the helper to print warnings and other information 126 | if len(helper.errors) > 0 { 127 | errs := make([]error, 0) 128 | for _, err := range helper.errors { 129 | errs = append(errs, fmt.Errorf(err)) 130 | } 131 | return helper, errors.Join(errs...) 132 | } 133 | 134 | return helper, nil 135 | } 136 | 137 | func createHelper(tp TemplateProvider, opts ParseOptions) (helper *AnalysisHelper, err error) { 138 | helper = &AnalysisHelper{ 139 | ctx: context.Background(), 140 | treeSet: make(map[string]*parse.Tree), 141 | 142 | errors: make([]string, 0), 143 | warnings: make([]string, 0), 144 | funcMap: opts.Funcs, 145 | } 146 | 147 | if len(opts.LeftDelim) == 0 || len(opts.RightDelim) == 0 { 148 | opts.LeftDelim = "{{" 149 | opts.RightDelim = "}}" 150 | } 151 | 152 | // create a tree of all fields for static type checking 153 | helper.fieldTree, err = createFieldTree(tp) 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | // create one big parse.Tree set of all templates, including embedded templates 159 | err = recurseFieldsImplementing[TemplateProvider](tp, func(tp TemplateProvider, field reflect.StructField) error { 160 | templateName, ok := field.Tag.Lookup("tmpl") 161 | if !ok { 162 | templateName = strings.TrimPrefix(field.Name, "*") 163 | } 164 | 165 | parser := parse.New(templateName) 166 | parser.Mode = parse.SkipFuncCheck | parse.ParseComments 167 | 168 | tmp := make(map[string]*parse.Tree) 169 | _, err := parser.Parse(tp.TemplateText(), opts.LeftDelim, opts.RightDelim, tmp, nil) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | for k, v := range tmp { 175 | helper.treeSet[k] = v 176 | } 177 | 178 | return nil 179 | }) 180 | 181 | return 182 | } 183 | -------------------------------------------------------------------------------- /analyzers.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "text/template/parse" 8 | ) 9 | 10 | type key string 11 | 12 | const ( 13 | visitedMapKey key = "visited" 14 | ) 15 | 16 | func setVisited(ctx context.Context, node parse.Node) context.Context { 17 | if m, ok := ctx.Value(visitedMapKey).(map[parse.Node]bool); ok { 18 | m[node] = true 19 | } else { 20 | return context.WithValue(ctx, visitedMapKey, map[parse.Node]bool{node: true}) 21 | } 22 | return ctx 23 | } 24 | 25 | func isVisited(ctx context.Context, node parse.Node) bool { 26 | if m, ok := ctx.Value(visitedMapKey).(map[parse.Node]bool); ok { 27 | return m[node] 28 | } 29 | return false 30 | } 31 | 32 | var builtinAnalyzers = []Analyzer{ 33 | staticTyping, 34 | } 35 | 36 | func staticTypingRecursive(prefix string, val reflect.Value, node parse.Node, helper *AnalysisHelper) { 37 | switch nodeTyp := node.(type) { 38 | case *parse.IfNode: 39 | for _, cmd := range nodeTyp.Pipe.Cmds { 40 | if len(cmd.Args) == 1 { 41 | switch argTyp := cmd.Args[0].(type) { 42 | case *parse.FieldNode: 43 | if isVisited(helper.ctx, argTyp) { 44 | break 45 | } 46 | typ := prefix + argTyp.String() 47 | field := helper.GetDefinedField(typ) 48 | if field == nil { 49 | helper.AddError(node, fmt.Sprintf("field %q not defined in struct %T", typ, val.Interface())) 50 | } else if kind, ok := field.IsKind(reflect.Bool); !ok { 51 | helper.AddError(node, fmt.Sprintf("field %q is not type bool: got %s", typ, kind)) 52 | } 53 | helper.WithContext(setVisited(helper.Context(), argTyp)) 54 | } 55 | } else { 56 | // this is a pipeline like {{ if eq .Arg "foo" }} 57 | if arg, ok := cmd.Args[0].(*parse.IdentifierNode); ok { 58 | if isVisited(helper.ctx, arg) { 59 | continue 60 | } 61 | 62 | switch arg.Ident { 63 | // TODO: generalize this to all function calls instead of just builtins 64 | case "eq", "ne", "lt", "le", "gt", "ge": 65 | if len(cmd.Args) != 3 { 66 | helper.AddError(node, fmt.Sprintf("invalid number of arguments for %q: expected 3, got %d", arg.Ident, len(cmd.Args))) 67 | } 68 | 69 | kind := make([]reflect.Kind, 2) 70 | for i, arg := range cmd.Args[1:] { 71 | switch argTyp := arg.(type) { 72 | case *parse.FieldNode: 73 | typ := prefix + argTyp.String() 74 | field := helper.GetDefinedField(typ) 75 | if field == nil && !isVisited(helper.ctx, argTyp) { 76 | helper.AddError(node, fmt.Sprintf("field %q not defined in struct %T", typ, val.Interface())) 77 | helper.WithContext(setVisited(helper.Context(), argTyp)) 78 | } else if field != nil { 79 | kind[i] = field.GetKind() 80 | } 81 | break 82 | 83 | case *parse.VariableNode: 84 | // account for the root scope ($) 85 | if argTyp.Ident[0] == "$" { 86 | typ := argTyp.Ident[1] 87 | field := helper.GetDefinedField(typ) 88 | if field == nil && !isVisited(helper.ctx, argTyp) { 89 | helper.AddError(node, fmt.Sprintf("field %q not defined in struct %T", typ, val.Interface())) 90 | helper.WithContext(setVisited(helper.Context(), argTyp)) 91 | } else if field != nil { 92 | kind[i] = field.GetKind() 93 | } 94 | } 95 | case *parse.StringNode: 96 | kind[i] = reflect.String 97 | break 98 | 99 | case *parse.NumberNode: 100 | if argTyp.IsInt { 101 | kind[i] = reflect.Int 102 | } else if argTyp.IsFloat { 103 | kind[i] = reflect.Float32 // TODO: will this break on Float64? 104 | } else if argTyp.IsUint { 105 | kind[i] = reflect.Uint 106 | } else if argTyp.IsComplex { 107 | kind[i] = reflect.Complex64 108 | } 109 | } 110 | } 111 | // check if arg1 and arg2 are comparable 112 | if kind[0] != kind[1] { 113 | // TODO(tylermmorton): there's a bug here where the helper 114 | // isn't detecting the correct kind of the field 115 | //helper.AddError(node, fmt.Sprintf("incompatible types for %q: %s and %s", arg.Ident, kind[0], kind[1])) 116 | } 117 | } 118 | 119 | helper.WithContext(setVisited(helper.Context(), arg)) 120 | } 121 | } 122 | } 123 | break 124 | 125 | case *parse.RangeNode: 126 | // TODO: this will break for {{ range }} statements with assignments: 127 | // {{ $i, $v := range .Arg }} 128 | var inferTyp = prefix 129 | // check the type of the argument passed to range: {{ range .Arg }} 130 | for _, cmd := range nodeTyp.Pipe.Cmds { 131 | for _, arg := range cmd.Args { 132 | switch argTyp := arg.(type) { 133 | case *parse.FieldNode: 134 | if isVisited(helper.ctx, argTyp) { 135 | break 136 | } 137 | inferTyp = prefix + argTyp.String() 138 | field := helper.GetDefinedField(inferTyp) 139 | if field == nil { 140 | helper.AddError(node, fmt.Sprintf("field %q not defined in struct %T", argTyp.String(), val.Interface())) 141 | } 142 | helper.WithContext(setVisited(helper.Context(), argTyp)) 143 | } 144 | } 145 | } 146 | 147 | // recurse on the body of the range loop using the inferred type 148 | Traverse(nodeTyp.List, func(node parse.Node) { 149 | staticTypingRecursive(inferTyp, val, node, helper) 150 | }) 151 | 152 | break 153 | 154 | case *parse.TemplateNode: 155 | if !helper.IsDefinedTemplate(nodeTyp.Name) { 156 | helper.AddError(node, fmt.Sprintf("template %q is not provided by struct %T or any of its embedded structs", nodeTyp.Name, val.Interface())) 157 | } else if nodeTyp.Pipe == nil { 158 | helper.AddError(node, fmt.Sprintf("template %q is not invoked with a pipeline", nodeTyp.Name)) 159 | } else if len(nodeTyp.Pipe.Cmds) == 1 { 160 | // TODO: here we can check the type of the pipeline 161 | // if the command is a DotNode, check the type of the struct for any embedded fields 162 | // if the command is a FieldNode, check the type of the field and mark it as visited 163 | _ = nodeTyp.Pipe.Cmds[0] 164 | } 165 | 166 | break 167 | 168 | // FieldNode is the last node that we want to check. Give a chance for analyzers 169 | // higher up in the parse tree to mark them as visited. 170 | case *parse.FieldNode: 171 | if isVisited(helper.ctx, nodeTyp) { 172 | break 173 | } 174 | 175 | typ := prefix + nodeTyp.String() 176 | field := helper.GetDefinedField(typ) 177 | if field == nil { 178 | helper.AddError(node, fmt.Sprintf("field %q not defined in struct %T", typ, val.Interface())) 179 | } 180 | helper.WithContext(setVisited(helper.Context(), nodeTyp)) 181 | 182 | // TODO: can we make further assertions here about the type of the field? 183 | 184 | break 185 | } 186 | } 187 | 188 | // staticTyping enables static type checking on templateProvider parse trees by using 189 | // reflection on the given struct type. 190 | var staticTyping Analyzer = func(helper *AnalysisHelper) AnalyzerFunc { 191 | return func(val reflect.Value, node parse.Node) { 192 | staticTypingRecursive("", val, node, helper) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /cmd/tmpl/Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | install: 5 | cmds: 6 | - go install tmpl.go 7 | -------------------------------------------------------------------------------- /cmd/tmpl/cmd/bind.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "fmt" 7 | "go/ast" 8 | "go/format" 9 | "go/parser" 10 | "go/token" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | var ( 21 | Outfile *string 22 | Mode *string 23 | 24 | //go:embed templates/_tmpl.tmpl 25 | tmplHelperTmplText string 26 | //go:embed templates/fileprovider.tmpl 27 | fileProviderTmplText string 28 | //go:embed templates/textprovider.tmpl 29 | textProviderTmplText string 30 | ) 31 | 32 | const ( 33 | BindPrefix string = "//tmpl:bind" 34 | 35 | // BinderTypeFile loads all templates from a file on disk 36 | BinderTypeFile string = "file" 37 | // BinderTypeEmbed loads all templates from go:embed 38 | BinderTypeEmbed string = "embed" 39 | ) 40 | 41 | type TemplateBinding struct { 42 | Args []string 43 | BinderType string 44 | FileName string 45 | FilePaths []string 46 | StructType string 47 | } 48 | 49 | func (b *TemplateBinding) TemplateText() string { 50 | if b.BinderType == BinderTypeEmbed { 51 | return textProviderTmplText 52 | } else if b.BinderType == BinderTypeFile { 53 | return fileProviderTmplText 54 | } else { 55 | panic(fmt.Sprintf("unknown binder type: %s", b.BinderType)) 56 | } 57 | } 58 | 59 | // bindCmd represents the bind command 60 | var bindCmd = &cobra.Command{ 61 | Use: "bind", 62 | Short: "Analyzes Go source code in search of //tmpl:bind comments and generates binder files", 63 | 64 | Args: cobra.ExactArgs(1), 65 | RunE: func(cmd *cobra.Command, args []string) error { 66 | outfile := cmd.Flags().Lookup("outfile") 67 | if outfile == nil { 68 | return fmt.Errorf("--outfile not set and no default was provided") 69 | } 70 | 71 | fileOrPath := args[0] 72 | if len(fileOrPath) == 0 { 73 | return fmt.Errorf("no file or path argument was provided") 74 | } 75 | 76 | cwd, err := os.Getwd() 77 | if err != nil { 78 | return fmt.Errorf("could not get current working directory: %v", err) 79 | } 80 | 81 | if strings.HasSuffix(fileOrPath, "...") { 82 | return bindGoPackage(filepath.Join(cwd, strings.TrimSuffix(fileOrPath, "...")), outfile.Value.String(), true) 83 | } 84 | 85 | fileOrPath = filepath.Join(cwd, fileOrPath) 86 | 87 | s, err := os.Stat(fileOrPath) 88 | if err != nil { 89 | return fmt.Errorf("failed to read file or path '%s': %+v", fileOrPath, err) 90 | } 91 | 92 | if s.IsDir() { 93 | return bindGoPackage(fileOrPath, filepath.Join(fileOrPath, outfile.Value.String()), false) 94 | } else { 95 | return bindGoFile(fileOrPath, filepath.Join(filepath.Dir(fileOrPath), outfile.Value.String())) 96 | } 97 | }, 98 | } 99 | 100 | func init() { 101 | rootCmd.AddCommand(bindCmd) 102 | 103 | Outfile = bindCmd.Flags().String("outfile", "tmpl.gen.go", "set the output go file for template bindings") 104 | Mode = bindCmd.Flags().String("mode", BinderTypeFile, "set the binder mode (embed|file)") 105 | if mode, ok := os.LookupEnv("TMPL_BIND_MODE"); Mode == nil && ok { 106 | Mode = &mode 107 | } 108 | } 109 | 110 | func analyzeGoFile(goFile string) []TemplateBinding { 111 | res := make([]TemplateBinding, 0) 112 | byt, err := os.ReadFile(goFile) 113 | if os.IsNotExist(err) || (byt != nil && len(byt) == 0) { 114 | panic(err) 115 | } else if err != nil { 116 | panic(err) 117 | } else { 118 | // Read the Go File and convert it to AST 119 | fset := token.NewFileSet() 120 | f, err := parser.ParseFile(fset, "", string(byt), parser.ParseComments) 121 | if err != nil { 122 | log.Printf("Unable to parse .go file '%s' as Go source:\n\t%+v", goFile, err) 123 | return res 124 | } 125 | 126 | for _, decl := range f.Decls { 127 | switch decl := decl.(type) { 128 | case *ast.GenDecl: 129 | if decl.Specs == nil { 130 | continue 131 | } 132 | 133 | if decl.Doc != nil { 134 | for _, comment := range decl.Doc.List { 135 | if strings.HasPrefix(comment.Text, BindPrefix) { 136 | if ts, ok := decl.Specs[0].(*ast.TypeSpec); ok { 137 | // TODO: refactor to separate function 138 | s := strings.Split(comment.Text, " ") 139 | pattern := filepath.Join(filepath.Dir(goFile), s[1]) 140 | matches, err := filepath.Glob(pattern) 141 | if err != nil { 142 | panic(fmt.Sprintf("failed to glob pattern '%s': %v", pattern, err)) 143 | } 144 | 145 | b := TemplateBinding{ 146 | Args: s[2:], 147 | FileName: s[1], 148 | FilePaths: matches, 149 | StructType: ts.Name.Name, 150 | BinderType: *Mode, 151 | } 152 | 153 | res = append(res, b) 154 | break 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | return res 164 | } 165 | 166 | func writeBinderFile(outfile string, packageName string, bindings []TemplateBinding) error { 167 | imports := make(map[string]string, 0) 168 | for _, binding := range bindings { 169 | switch binding.BinderType { 170 | case BinderTypeEmbed: 171 | imports["embed"] = "" 172 | imports["path/filepath"] = "" 173 | imports["strings"] = "" 174 | case BinderTypeFile: 175 | imports["bytes"] = "" 176 | imports["os"] = "" 177 | } 178 | } 179 | 180 | log.Printf("Generating '%s'", outfile) 181 | 182 | b := bytes.Buffer{} 183 | b.WriteString(fmt.Sprintf("package %s\n\n", packageName)) 184 | 185 | b.WriteString("// /!\\ THIS FILE IS GENERATED DO NOT EDIT /!\\\n\n") 186 | 187 | b.WriteString("import (\n") 188 | for k, alias := range imports { 189 | b.WriteString(fmt.Sprintf("\t%s \"%s\"\n", alias, k)) 190 | } 191 | b.WriteString(")\n") 192 | 193 | if *Mode == BinderTypeEmbed { 194 | b.WriteString(tmplHelperTmplText) 195 | b.WriteString("\n") 196 | } 197 | 198 | for _, binding := range bindings { 199 | log.Printf("- write binder for %s %s", binding.StructType, strings.Join(binding.Args, " ")) 200 | 201 | t := template.New("binder").Funcs(template.FuncMap{ 202 | "toCamelCase": toCamelCase, 203 | }) 204 | 205 | t, err := t.Parse(binding.TemplateText()) 206 | if err != nil { 207 | return fmt.Errorf("could not parse binder template: %v", err) 208 | } 209 | 210 | err = t.Execute(&b, &binding) 211 | if err != nil { 212 | return fmt.Errorf("could not execute binder template: %v", err) 213 | } 214 | } 215 | 216 | src, err := format.Source([]byte(b.String())) 217 | if err != nil { 218 | fmt.Printf(b.String() + "\n\n") 219 | return fmt.Errorf("could not format binder file: %v", err) 220 | } 221 | 222 | err = os.WriteFile(outfile, src, 0644) 223 | if err != nil { 224 | return fmt.Errorf("could not write binder file: %v", err) 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func bindGoFile(goFile string, outFile string) error { 231 | return writeBinderFile(outFile, filepath.Base(filepath.Dir(goFile)), analyzeGoFile(goFile)) 232 | } 233 | 234 | func bindGoPackage(dir, outFile string, recursive bool) error { 235 | bindings := make([]TemplateBinding, 0) 236 | entries, err := os.ReadDir(dir) 237 | if err != nil { 238 | return fmt.Errorf("could not read current working directory: %v", err) 239 | } 240 | 241 | for _, entry := range entries { 242 | if entry.IsDir() && recursive { 243 | err := bindGoPackage(filepath.Join(dir, entry.Name()), outFile, recursive) 244 | if err != nil { 245 | return err 246 | } 247 | } else if strings.HasSuffix(entry.Name(), ".go") { 248 | bindings = append(bindings, analyzeGoFile(filepath.Join(dir, entry.Name()))...) 249 | } 250 | } 251 | 252 | if len(bindings) == 0 { 253 | return nil 254 | } 255 | 256 | return writeBinderFile(filepath.Join(dir, outFile), filepath.Base(dir), bindings) 257 | } 258 | -------------------------------------------------------------------------------- /cmd/tmpl/cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "log" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // rootCmd represents the base command when called without any subcommands 14 | var rootCmd = &cobra.Command{ 15 | Use: "tmpl", 16 | Short: "tmpl is a html/template toolchain", 17 | Long: `https://github.com/tylermmorton/tmpl`, 18 | // Run: func(cmd *cobra.Command, args []string) { }, 19 | } 20 | 21 | // Execute adds all child commands to the root command and sets flags appropriately. 22 | // This is called by main.main(). It only needs to happen once to the rootCmd. 23 | func Execute() { 24 | err := rootCmd.Execute() 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | 30 | func init() {} 31 | 32 | // Converts snake_case to camelCase 33 | func toCamelCase(inputUnderScoreStr string) (camelCase string) { 34 | flag := false 35 | for k, v := range inputUnderScoreStr { 36 | if k == 0 { 37 | camelCase = strings.ToUpper(string(inputUnderScoreStr[0])) 38 | } else { 39 | if flag { 40 | camelCase += strings.ToUpper(string(v)) 41 | flag = false 42 | } else { 43 | if v == '-' || v == '_' { 44 | flag = true 45 | } else { 46 | camelCase += string(v) 47 | } 48 | } 49 | } 50 | } 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /cmd/tmpl/cmd/templates/_tmpl.tmpl: -------------------------------------------------------------------------------- 1 | func _tmpl(fsys embed.FS, path string) string { 2 | builder := &strings.Builder{} 3 | entries, err := fsys.ReadDir(path) 4 | if err != nil { 5 | panic(err) 6 | } 7 | for _, entry := range entries { 8 | if entry.IsDir() { 9 | builder.WriteString(_tmpl(fsys, filepath.Join(path, entry.Name()))) 10 | } else { 11 | byt, err := fsys.ReadFile(filepath.Join(path, entry.Name())) 12 | if err != nil { 13 | panic(err) 14 | } 15 | builder.Write(byt) 16 | } 17 | } 18 | return builder.String() 19 | } -------------------------------------------------------------------------------- /cmd/tmpl/cmd/templates/fileprovider.tmpl: -------------------------------------------------------------------------------- 1 | func (t *{{ .StructType }}) TemplateText() string { 2 | var files = []string{ 3 | {{ range .FilePaths -}} 4 | "{{ . }}", 5 | {{ end -}} 6 | } 7 | var buf = &bytes.Buffer{} 8 | for _, file := range files { 9 | byt, err := os.ReadFile(file) 10 | if err != nil { 11 | panic(err) 12 | } 13 | buf.Write(byt) 14 | } 15 | return buf.String() 16 | } 17 | -------------------------------------------------------------------------------- /cmd/tmpl/cmd/templates/textprovider.tmpl: -------------------------------------------------------------------------------- 1 | //go:embed {{ .FileName }} 2 | var {{ .StructType | toCamelCase }}TmplFS embed.FS 3 | 4 | func (t *{{ .StructType }}) TemplateText() string { 5 | return _tmpl({{ .StructType | toCamelCase }}TmplFS, ".") 6 | } 7 | -------------------------------------------------------------------------------- /cmd/tmpl/tmpl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/tylermmorton/tmpl/cmd/tmpl/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /compile.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "reflect" 7 | "sync" 8 | ) 9 | 10 | // CompilerOptions holds options that control the template compiler 11 | type CompilerOptions struct { 12 | // analyzers is a list of analyzers that are run before compilation 13 | analyzers []Analyzer 14 | // parseOpts are the options passed to the template parser 15 | parseOpts ParseOptions 16 | } 17 | 18 | // CompilerOption is a function that can be used to modify the CompilerOptions 19 | type CompilerOption func(opts *CompilerOptions) 20 | 21 | func UseFuncs(funcs FuncMap) CompilerOption { 22 | return func(opts *CompilerOptions) { 23 | if opts.parseOpts.Funcs == nil { 24 | opts.parseOpts.Funcs = funcs 25 | } else { 26 | for k, v := range funcs { 27 | opts.parseOpts.Funcs[k] = v 28 | } 29 | } 30 | } 31 | } 32 | 33 | func UseAnalyzers(analyzers ...Analyzer) CompilerOption { 34 | return func(opts *CompilerOptions) { 35 | opts.analyzers = append(opts.analyzers, analyzers...) 36 | } 37 | } 38 | 39 | // UseParseOptions sets the ParseOptions for the template CompilerOptions. These 40 | // options are used internally with the html/template package. 41 | func UseParseOptions(parseOpts ParseOptions) CompilerOption { 42 | return func(opts *CompilerOptions) { 43 | opts.parseOpts = parseOpts 44 | } 45 | } 46 | 47 | func compile(tp TemplateProvider, opts ParseOptions, analyzers ...Analyzer) (*template.Template, error) { 48 | var ( 49 | err error 50 | t *template.Template 51 | ) 52 | 53 | helper, err := Analyze(tp, opts, analyzers) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | // recursively parse all templates into a single template instance 59 | // this block is responsible for constructing the template that 60 | // will be rendered by the user 61 | err = recurseFieldsImplementing[TemplateProvider](tp, func(tp TemplateProvider, field reflect.StructField) error { 62 | var ( 63 | funcMap = make(FuncMap) 64 | templateText string 65 | ) 66 | 67 | templateName, ok := field.Tag.Lookup("tmpl") 68 | if !ok { 69 | templateName = field.Name 70 | } 71 | 72 | if t == nil { 73 | // if t is nil, that means this is the recursive entrypoint 74 | // and some construction needs to happen 75 | t = template.New(templateName) 76 | templateText = tp.TemplateText() 77 | 78 | t = t.Delims(opts.LeftDelim, opts.RightDelim) 79 | 80 | // Analyzers can provide functions to be used in templates 81 | for key, fn := range helper.FuncMap() { 82 | funcMap[key] = fn 83 | } 84 | 85 | // FuncMapProvider can also be implemented and provide functions 86 | err = recurseFieldsImplementing[FuncMapProvider](tp, func(val FuncMapProvider, field reflect.StructField) error { 87 | for key, fn := range val.TemplateFuncMap() { 88 | funcMap[key] = fn 89 | } 90 | return nil 91 | }) 92 | if err != nil { 93 | return err 94 | } 95 | if fmp, ok := tp.(FuncMapProvider); ok { 96 | for key, fn := range fmp.TemplateFuncMap() { 97 | funcMap[key] = fn 98 | } 99 | } 100 | } else { 101 | // if this is a nested template wrap its text in a {{ define }} 102 | // statement, so it may be referenced by the "parent" template 103 | // ex: {{define %q -}}\n%s{{end}} 104 | templateText = fmt.Sprintf("%[1]sdefine %[3]q -%[2]s\n%[4]s%[1]send%[2]s\n", opts.LeftDelim, opts.RightDelim, templateName, tp.TemplateText()) 105 | } 106 | 107 | t, err = t.Funcs(funcMap).Parse(templateText) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | }) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to compile template: %+v", err) 116 | } 117 | 118 | return t, nil 119 | } 120 | 121 | // Compile takes the given TemplateProvider, parses the templateProvider text and then 122 | // recursively compiles all nested templates into one managed Template instance. 123 | func Compile[T TemplateProvider](tp T, opts ...CompilerOption) (Template[T], error) { 124 | var ( 125 | c = &CompilerOptions{ 126 | analyzers: builtinAnalyzers, 127 | parseOpts: ParseOptions{ 128 | LeftDelim: "{{", 129 | RightDelim: "}}", 130 | }, 131 | } 132 | ) 133 | 134 | for _, opt := range opts { 135 | opt(c) 136 | } 137 | 138 | m := &managedTemplate[T]{ 139 | mu: &sync.RWMutex{}, 140 | } 141 | 142 | doCompile := func() (err error) { 143 | var ( 144 | t *template.Template 145 | ) 146 | 147 | t, err = compile(tp, c.parseOpts, c.analyzers...) 148 | if err != nil { 149 | return 150 | } 151 | 152 | m.mu.Lock() 153 | m.template = t 154 | m.mu.Unlock() 155 | 156 | return 157 | } 158 | 159 | err := doCompile() 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return m, nil 165 | } 166 | 167 | // MustCompile is a helper function that wraps Compile and panics if the template 168 | // fails to compile. 169 | func MustCompile[T TemplateProvider](p T, opts ...CompilerOption) Template[T] { 170 | tmpl, err := Compile(p, opts...) 171 | if err != nil { 172 | panic(err) 173 | } 174 | return tmpl 175 | } 176 | -------------------------------------------------------------------------------- /compile_test.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "html/template" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | . "github.com/tylermmorton/tmpl/testdata" 12 | ) 13 | 14 | type TestTemplate struct { 15 | // Name tests fields who do not implement TemplateProvider 16 | Name string 17 | // Content tests unnamed TemplateProviders 18 | Content *TextComponent 19 | // Title tests struct values 20 | Title string 21 | // Components tests slices of nested templates 22 | Scripts []ScriptComponent `tmpl:"script"` 23 | } 24 | 25 | //go:embed testdata/compiler_test.tmpl.html 26 | var testTemplateText string 27 | 28 | func (*TestTemplate) TemplateText() string { 29 | return testTemplateText 30 | } 31 | 32 | func (*TestTemplate) TemplateFuncMap() FuncMap { 33 | return FuncMap{ 34 | "testFuncMap": func() string { return "testFunc result" }, 35 | } 36 | } 37 | 38 | // Test_Compile tests the compiler's ability to compile and render templates. 39 | // It's like a package level integration test at this point 40 | func Test_Compile(t *testing.T) { 41 | testCases := map[string]struct { 42 | templateProvider TemplateProvider 43 | renderOptions []RenderOption 44 | 45 | expectRenderOutput []string 46 | expectRenderErrMsg string 47 | 48 | expectCompileErrMsg string 49 | }{ 50 | // tmpl should support all html/template syntax. these test cases are 51 | // to ensure the compiler is not breaking any of the syntax. for sanity 52 | "Supports usage of {{ . }} pipeline statements": { 53 | templateProvider: &TextComponent{Text: "Hello World"}, 54 | expectRenderOutput: []string{"Hello World"}, 55 | }, 56 | "Supports usage of {{ .Field }} pipeline statements": { 57 | templateProvider: &DefinedField{DefField: "Hello World"}, 58 | expectRenderOutput: []string{"Hello World"}, 59 | }, 60 | "Supports usage of {{ .Nested.Field }} pipeline statements": { 61 | templateProvider: &DefinedNestedField{Nested: DefinedField{DefField: "Hello World"}}, 62 | expectRenderOutput: []string{"Hello World"}, 63 | }, 64 | "Supports usage of FuncMapProvider to provide static template functions": { 65 | templateProvider: &TestTemplate{ 66 | Title: "FuncMapProvider", 67 | Scripts: []ScriptComponent{}, 68 | Content: &TextComponent{Text: "Hello World"}, 69 | }, 70 | expectRenderOutput: []string{ 71 | "FuncMapProvider", 72 | "", 75 | }, 76 | }, 77 | "Supports usage of {{ define }} and {{ template }} statements": { 78 | templateProvider: &TestTemplate{ 79 | Title: "Test", 80 | Scripts: []ScriptComponent{}, 81 | Content: &TextComponent{Text: "Hello World"}, 82 | }, 83 | expectRenderOutput: []string{ 84 | "Test", 85 | "Hello World", 86 | "
", 87 | }, 88 | }, 89 | "Supports usage of {{ if }} statements with bare fields": { 90 | templateProvider: &DefinedIf{DefIf: true, Message: "Hello World"}, 91 | expectRenderOutput: []string{ 92 | "Hello World", 93 | }, 94 | }, 95 | "Supports usage of builtin equality operations in {{ if eq .Field 1 }} pipelines": { 96 | templateProvider: &PipelineIf{ 97 | DefInt: 1, 98 | Message: "Hello World", 99 | }, 100 | expectRenderOutput: []string{ 101 | "Hello World", 102 | }, 103 | }, 104 | "Supports usage of {{ range }} statements over string types": { 105 | templateProvider: &DefinedRange{DefList: []string{"Hello", "World"}}, 106 | expectRenderOutput: []string{ 107 | "Hello", 108 | "World", 109 | }, 110 | }, 111 | "Supports usage of {{ range }} statements over anonymous struct types": { 112 | templateProvider: &StructRange{DefList: []struct { 113 | DefField string 114 | }{ 115 | {DefField: "Hello"}, 116 | {DefField: "World"}, 117 | }}, 118 | expectRenderOutput: []string{ 119 | "Hello", 120 | "World", 121 | }, 122 | }, 123 | "Supports usage of {{ range }} statements over named struct types": { 124 | templateProvider: &NamedStructRange{NamedStructs: []NamedStruct{ 125 | {DefField: "Hello"}, 126 | {DefField: "World"}, 127 | }}, 128 | expectRenderOutput: []string{ 129 | "Hello", 130 | "World", 131 | }, 132 | }, 133 | 134 | "Supports usage of {{ if }} statements within {{ range }} bodies": { 135 | templateProvider: &IfWithinRange{ 136 | DefList: []DefinedIf{ 137 | {DefIf: true, Message: "Hello"}, 138 | }, 139 | }, 140 | expectRenderOutput: []string{ 141 | "Hello", 142 | }, 143 | }, 144 | "Supports usage of {{ range }} statements within {{ range }} bodies": { 145 | templateProvider: &StructRangeWithinRange{ 146 | ListOne: []StructOne{ 147 | { 148 | ListTwo: []StructTwo{ 149 | {DefField: "Hello"}, 150 | }, 151 | }, 152 | { 153 | ListTwo: []StructTwo{ 154 | {DefField: "World"}, 155 | }, 156 | }, 157 | }, 158 | }, 159 | expectRenderOutput: []string{ 160 | "Hello", 161 | "World", 162 | }, 163 | }, 164 | // template nesting tests 165 | "Supports embedded struct fields": { 166 | templateProvider: &EmbeddedField{ 167 | EmbeddedStruct: EmbeddedStruct{DefField: "Hello World"}, 168 | }, 169 | expectRenderOutput: []string{"Hello World"}, 170 | }, 171 | "Supports multiple levels of embedded TemplateProviders": { 172 | templateProvider: &MultiLevelEmbeds{ 173 | LevelOneEmbed: LevelOneEmbed{ 174 | LevelTwoEmbed: LevelTwoEmbed{ 175 | DefField: "Hello World", 176 | }, 177 | }, 178 | }, 179 | expectRenderOutput: []string{"Hello World"}, 180 | }, 181 | "Supports nested TemplateProviders that are not embedded": { 182 | templateProvider: &Parent{ 183 | N: Child{ 184 | DefField: "Hello World", 185 | }, 186 | }, 187 | expectRenderOutput: []string{"Hello World"}, 188 | }, 189 | 190 | // layout & outlet tests (RenderOption tests) 191 | "Supports usage of WithTarget and WithName when rendering templates": { 192 | templateProvider: &Outlet{ 193 | Layout: Layout{}, 194 | Content: "Hello World", 195 | }, 196 | renderOptions: []RenderOption{ 197 | WithName("outlet"), 198 | WithTarget("layout"), 199 | }, 200 | expectRenderOutput: []string{"Hello World"}, 201 | }, 202 | "Supports usage of WithTarget and WithName when rendering templates with nested outlets": { 203 | templateProvider: &OutletWithNested{ 204 | Layout: Layout{}, 205 | LevelOneEmbed: LevelOneEmbed{ 206 | LevelTwoEmbed: LevelTwoEmbed{ 207 | DefField: "Hello World", 208 | }, 209 | }, 210 | }, 211 | renderOptions: []RenderOption{ 212 | WithName("outlet"), 213 | WithTarget("layout"), 214 | }, 215 | expectRenderOutput: []string{"Hello World"}, 216 | }, 217 | "Supports usage of WithTarget and WithName when rendering layouts with nested templates": { 218 | templateProvider: &OutletWithNestedLayout{ 219 | LayoutWithNested: LayoutWithNested{ 220 | DefinedField: DefinedField{ 221 | DefField: "Hi", 222 | }, 223 | }, 224 | Content: "Hello World", 225 | }, 226 | renderOptions: []RenderOption{ 227 | WithName("outlet"), 228 | WithTarget("layout"), 229 | }, 230 | expectRenderOutput: []string{"Hi\\nHello World"}, 231 | }, 232 | "Supports usage of $ dot reference within range scopes": { 233 | templateProvider: &DollarSignWithinRange{ 234 | DefList: []string{"1", "2"}, 235 | DefStr: "Hello", 236 | }, 237 | expectRenderOutput: []string{"HelloHello"}, 238 | }, 239 | "Supports usage of $ dot reference within an if within range scopes": { 240 | templateProvider: &DollarSignWithinIfWithinRange{ 241 | DefList: []string{"Hello", "World"}, 242 | DefStr: "Hello", 243 | }, 244 | expectRenderOutput: []string{"PASS", "FAIL"}, 245 | }, 246 | 247 | // these are test cases for the compiler's built-in analyzers 248 | "Catches usage of {{ template }} statements containing undefined template names": { 249 | templateProvider: &UndefinedTemplate{}, 250 | expectCompileErrMsg: "template \"undefined\" is not provided", 251 | }, 252 | "Catches usage of {{ template }} statements without a pipeline": { 253 | templateProvider: &NoPipeline{ 254 | LevelOneEmbed: LevelOneEmbed{}, 255 | }, 256 | expectCompileErrMsg: "template \"one\" is not invoked with a pipeline", 257 | }, 258 | "Catches usage of {{ if }} statements containing non-bool types": { 259 | templateProvider: &AnyTypeIf{DefIf: 0}, 260 | expectCompileErrMsg: "field \".DefIf\" is not type bool: got int", 261 | }, 262 | "Catches usage of {{ if }} statements containing undefined fields": { 263 | templateProvider: &UndefinedIf{}, 264 | expectCompileErrMsg: "field \".UndIf\" not defined", 265 | }, 266 | "Catches usage of {{ range }} statements containing undefined fields": { 267 | templateProvider: &UndefinedRange{}, 268 | expectCompileErrMsg: "field \".UndList\" not defined", 269 | }, 270 | "Catches usage of undefined fields": { 271 | templateProvider: &UndefinedField{}, 272 | expectCompileErrMsg: "field \".UndField\" not defined", 273 | }, 274 | "Catches usage of undefined nested fields": { 275 | templateProvider: &UndefinedNestedField{Nested: UndefinedField{}}, 276 | expectCompileErrMsg: "field \".Nested.UndField\" not defined", 277 | }, 278 | } 279 | 280 | for name, tc := range testCases { 281 | t.Run(name, func(t *testing.T) { 282 | tmpl, err := Compile(tc.templateProvider) 283 | if err != nil { 284 | if len(tc.expectCompileErrMsg) == 0 { 285 | t.Fatal(err) 286 | } else if !strings.Contains(err.Error(), tc.expectCompileErrMsg) { 287 | t.Fatalf("expected compile error message to contain %q, got %q", tc.expectCompileErrMsg, err.Error()) 288 | } else { 289 | return 290 | } 291 | } 292 | 293 | buf := bytes.Buffer{} 294 | err = tmpl.Render(&buf, tc.templateProvider, tc.renderOptions...) 295 | if err != nil { 296 | if len(tc.expectRenderErrMsg) == 0 { 297 | t.Fatal(err) 298 | } else if !strings.Contains(err.Error(), tc.expectRenderErrMsg) { 299 | t.Fatalf("expected render error message to contain %q, got %q", tc.expectRenderErrMsg, err.Error()) 300 | } else { 301 | return 302 | } 303 | } 304 | 305 | for _, expect := range tc.expectRenderOutput { 306 | if !strings.Contains(buf.String(), expect) { 307 | t.Fatalf("expected render output to contain %q, got %q", expect, buf.String()) 308 | } 309 | } 310 | }) 311 | } 312 | } 313 | 314 | func TestCompile_DeeplyNestedTemplateProviders(t *testing.T) { 315 | templateProvider := &DeeplyNestedTemplateProvider{ 316 | Nested: NestedTemplateProvider{ 317 | Text: TextComponent{Text: template.HTML("Hello World")}, 318 | }, 319 | } 320 | 321 | tmpl, err := Compile(templateProvider) 322 | require.NoError(t, err) 323 | 324 | buf := bytes.Buffer{} 325 | err = tmpl.Render(&buf, templateProvider) 326 | require.NoError(t, err) 327 | 328 | require.Equal(t, "Hello World", buf.String()) 329 | } 330 | 331 | func TestCompile_DeeplyNestedTemplateProviderSlice(t *testing.T) { 332 | templateProvider := &DeeplyNestedTemplateProviderSlice{ 333 | Nested: []NestedTemplateProvider{{ 334 | Text: TextComponent{Text: template.HTML("Hello World")}, 335 | }}, 336 | } 337 | 338 | tmpl, err := Compile(templateProvider) 339 | require.NoError(t, err) 340 | 341 | buf := bytes.Buffer{} 342 | err = tmpl.Render(&buf, templateProvider) 343 | require.NoError(t, err) 344 | 345 | require.Equal(t, "Hello World", buf.String()) 346 | } 347 | -------------------------------------------------------------------------------- /funcmap.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | // FuncMapProvider is a struct type that returns its corresponding template functions. 4 | // To be used in conjunction with the TemplateProvider interface. 5 | type FuncMapProvider interface { 6 | TemplateFuncMap() FuncMap 7 | } 8 | -------------------------------------------------------------------------------- /funcmap_test.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type AddFunctionComponent struct { 12 | A, B int 13 | } 14 | 15 | func (*AddFunctionComponent) TemplateText() string { 16 | return `{{ add .A .B }}` 17 | } 18 | 19 | func (*AddFunctionComponent) TemplateFuncMap() FuncMap { 20 | return FuncMap{ 21 | "add": func(a, b int) string { 22 | return fmt.Sprintf("%d", a+b) 23 | }, 24 | } 25 | } 26 | 27 | type SubFunctionComponent struct { 28 | A, B int 29 | } 30 | 31 | func (*SubFunctionComponent) TemplateText() string { 32 | return `{{ sub .A .B }}` 33 | } 34 | 35 | func (*SubFunctionComponent) TemplateFuncMap() FuncMap { 36 | return FuncMap{ 37 | "sub": func(a, b int) string { 38 | return fmt.Sprintf("%d", a-b) 39 | }, 40 | } 41 | } 42 | 43 | type MergedFunctionComponent struct { 44 | A, B int 45 | AddFunctionComponent 46 | SubFunctionComponent 47 | } 48 | 49 | func (*MergedFunctionComponent) TemplateText() string { 50 | return `{{ add .A .B }}, {{ sub .A .B }}` 51 | } 52 | 53 | type NestedFunctionComponent struct { 54 | A, B int 55 | Nested struct { 56 | AddFunctionComponent 57 | Nested struct { 58 | SubFunctionComponent 59 | } 60 | } 61 | } 62 | 63 | func (*NestedFunctionComponent) TemplateText() string { 64 | return `{{ add .A .B }}, {{ sub .A .B }}` 65 | } 66 | 67 | func TestCompile_FuncMapProvider(t *testing.T) { 68 | t.Run("success", func(t *testing.T) { 69 | templateProvider := &AddFunctionComponent{ 70 | A: 1, 71 | B: 2, 72 | } 73 | 74 | tmpl, err := Compile(templateProvider) 75 | require.NoError(t, err) 76 | 77 | buf := bytes.Buffer{} 78 | err = tmpl.Render(&buf, templateProvider) 79 | require.NoError(t, err) 80 | 81 | require.Equal(t, "3", buf.String()) 82 | }) 83 | 84 | t.Run("merged_func_map_providers", func(t *testing.T) { 85 | templateProvider := &MergedFunctionComponent{ 86 | A: 1, 87 | B: 2, 88 | } 89 | 90 | tmpl, err := Compile(templateProvider) 91 | require.NoError(t, err) 92 | 93 | buf := bytes.Buffer{} 94 | err = tmpl.Render(&buf, templateProvider) 95 | require.NoError(t, err) 96 | 97 | require.Equal(t, "3, -1", buf.String()) 98 | }) 99 | 100 | t.Run("nested_func_map_providers", func(t *testing.T) { 101 | templateProvider := &NestedFunctionComponent{ 102 | A: 1, 103 | B: 2, 104 | } 105 | 106 | tmpl, err := Compile(templateProvider) 107 | require.NoError(t, err) 108 | 109 | buf := bytes.Buffer{} 110 | err = tmpl.Render(&buf, templateProvider) 111 | require.NoError(t, err) 112 | 113 | require.Equal(t, "3, -1", buf.String()) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tylermmorton/tmpl 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/spf13/cobra v1.7.0 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/spf13/pflag v1.0.5 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 10 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 11 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 12 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 13 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 14 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /reflect.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type FieldNode struct { 9 | Value reflect.Value 10 | StructField reflect.StructField 11 | 12 | Parent *FieldNode 13 | Children []*FieldNode 14 | } 15 | 16 | func (node *FieldNode) IsKind(kind reflect.Kind) (reflect.Kind, bool) { 17 | if node.StructField.Type.Kind() == reflect.Interface && node.Value.Kind() != kind { 18 | return node.Value.Kind(), false 19 | } else if node.StructField.Type.Kind() != kind { 20 | return node.StructField.Type.Kind(), false 21 | } else { 22 | return kind, true 23 | } 24 | } 25 | 26 | func (node *FieldNode) GetKind() reflect.Kind { 27 | if node.StructField.Type.Kind() == reflect.Interface { 28 | return node.Value.Kind() 29 | } else { 30 | return node.StructField.Type.Kind() 31 | } 32 | } 33 | 34 | func (node *FieldNode) FindPath(path []string) *FieldNode { 35 | if len(path) == 0 { 36 | return node 37 | } 38 | 39 | for _, child := range node.Children { 40 | if child.StructField.Name == path[0] { 41 | return child.FindPath(path[1:]) 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // createFieldTree can be used to create a tree structure of the fields in a struct 49 | func createFieldTree(structOrPtr interface{}) (root *FieldNode, err error) { 50 | root = &FieldNode{ 51 | Value: reflect.ValueOf(structOrPtr), 52 | StructField: reflect.StructField{ 53 | Name: fmt.Sprintf("%T", structOrPtr), 54 | }, 55 | 56 | Parent: nil, 57 | Children: make([]*FieldNode, 0), 58 | } 59 | 60 | if root.Value.Kind() == reflect.Ptr { 61 | // detect all methods on this pointer 62 | val := root.Value 63 | for i := 0; i < val.NumMethod(); i++ { 64 | methodVal := val.Method(i) 65 | methodTyp := val.Type().Method(i) 66 | 67 | node := &FieldNode{ 68 | Value: methodVal, 69 | StructField: reflect.StructField{ 70 | Name: methodTyp.Name, 71 | Type: methodTyp.Type, 72 | }, 73 | Parent: root, 74 | Children: make([]*FieldNode, 0), 75 | } 76 | root.Children = append(root.Children, node) 77 | 78 | // for each of the values returned by this method, 79 | // create a field tree and append it as a child 80 | for j := 0; j < methodVal.Type().NumOut(); j++ { 81 | retTyp := methodVal.Type().Out(j) 82 | var retVal reflect.Value 83 | if retTyp.Kind() == reflect.Ptr { 84 | retVal = reflect.New(retTyp.Elem()) 85 | } else { 86 | retVal = reflect.New(retTyp).Elem() 87 | } 88 | 89 | retTypSwitch: 90 | switch retTyp.Kind() { 91 | case reflect.Ptr: 92 | fallthrough 93 | case reflect.Struct: 94 | // check for circular dependencies, if found, append the parent node 95 | // as a child of the returned tree instead of recurring again 96 | for temp := node.Parent; temp != nil; temp = temp.Parent { 97 | if temp.Value.Type() == retTyp { 98 | node.Children = append(node.Children, temp.Children...) 99 | break retTypSwitch 100 | } 101 | } 102 | 103 | tree, err := createFieldTree(retVal.Interface()) 104 | if err != nil { 105 | return root, err 106 | } 107 | // the children of the returned tree should be children of this node 108 | node.Children = append(node.Children, tree.Children...) 109 | } 110 | } 111 | 112 | } 113 | 114 | // convert this pointer to a value 115 | root.Value = val.Elem() 116 | } 117 | 118 | if root.Value.Kind() != reflect.Struct { 119 | return 120 | } 121 | 122 | val := root.Value 123 | for i := 0; i < val.NumField(); i++ { 124 | iface := zeroValueInterfaceFromField(val.Field(i)) 125 | if iface != nil { 126 | node, err := createFieldTree(iface) 127 | if err != nil { 128 | return nil, err 129 | } 130 | node.StructField = val.Type().Field(i) 131 | node.Parent = root 132 | root.Children = append(root.Children, node) 133 | 134 | //support embedded struct fields 135 | if node.StructField.Anonymous { 136 | for _, child := range node.Children { 137 | child.Parent = root 138 | root.Children = append(root.Children, child) 139 | } 140 | } 141 | } else if val.Field(i).Kind() == reflect.Struct { 142 | node := &FieldNode{ 143 | Value: val.Field(i), 144 | StructField: val.Type().Field(i), 145 | Parent: root, 146 | Children: make([]*FieldNode, 0), 147 | } 148 | root.Children = append(root.Children, node) 149 | } else { 150 | node := &FieldNode{ 151 | Value: val.Field(i), 152 | StructField: reflect.StructField{ 153 | Name: val.Type().Field(i).Name, 154 | Type: val.Type().Field(i).Type, 155 | }, 156 | Parent: root, 157 | Children: make([]*FieldNode, 0), 158 | } 159 | root.Children = append(root.Children, node) 160 | } 161 | } 162 | 163 | return root, nil 164 | } 165 | 166 | func recurseFieldsImplementing[T interface{}](structOrPtr interface{}, fn func(val T, field reflect.StructField) error) error { 167 | val := reflect.ValueOf(structOrPtr) 168 | if val.Kind() == reflect.Ptr { 169 | val = val.Elem() 170 | } 171 | 172 | iface := zeroValueInterfaceFromField(val) 173 | if t, ok := iface.(T); ok { 174 | err := fn(t, reflect.StructField{ 175 | Name: fmt.Sprintf("%T", structOrPtr), 176 | }) 177 | if err != nil { 178 | return err 179 | } 180 | } 181 | 182 | for i := 0; i < val.NumField(); i++ { 183 | field := val.Field(i) 184 | if field.Kind() != reflect.Ptr && 185 | field.Kind() != reflect.Slice && 186 | field.Kind() != reflect.Struct { 187 | continue 188 | } 189 | 190 | iface := zeroValueInterfaceFromField(field) 191 | if t, ok := iface.(T); ok { 192 | err := fn(t, val.Type().Field(i)) 193 | if err != nil { 194 | return err 195 | } 196 | } 197 | 198 | if field.Kind() == reflect.Slice { 199 | // Get the underlying type of this slice 200 | underlyingType := field.Type().Elem() 201 | if underlyingType.Kind() != reflect.Ptr && 202 | underlyingType.Kind() != reflect.Struct { 203 | continue 204 | } 205 | 206 | iface = zeroValueInterfaceFromField(field) 207 | } else if field.Kind() != reflect.Struct { 208 | // If this is not a struct or pointer, we can't recurse 209 | continue 210 | } 211 | 212 | // Even if this field is not the interface we're looking for, its 213 | // child fields might be... So recurse on 214 | err := recurseFieldsImplementing[T](iface, fn) 215 | if err != nil { 216 | return err 217 | } 218 | } 219 | 220 | return nil 221 | } 222 | 223 | // zeroValueInterfaceFromField converts a reflected field to a zero'd version of itself as an interface type. 224 | // this makes it easier to perform type assertions on reflected struct fields 225 | func zeroValueInterfaceFromField(field reflect.Value) interface{} { 226 | switch field.Kind() { 227 | case reflect.Struct: 228 | if field.Type().Kind() == reflect.Ptr { 229 | return reflect.New(field.Type().Elem()).Interface() 230 | } else { 231 | return reflect.New(field.Type()).Interface() 232 | } 233 | case reflect.Ptr: 234 | fallthrough 235 | case reflect.Slice: 236 | if field.Type().Elem().Kind() == reflect.Ptr { 237 | return reflect.New(field.Type().Elem().Elem()).Interface() 238 | } else { 239 | return reflect.New(field.Type().Elem()).Interface() 240 | } 241 | } 242 | return nil 243 | } 244 | -------------------------------------------------------------------------------- /reflect_test.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | type testFieldTree struct { 9 | } 10 | 11 | func (*testFieldTree) Method1() error { 12 | return nil 13 | } 14 | 15 | type testReturnType struct { 16 | Field1 string 17 | } 18 | 19 | func (*testFieldTree) Method2() (*testReturnType, error) { 20 | return &testReturnType{}, nil 21 | } 22 | 23 | func (t *testFieldTree) Method3() *testFieldTree { 24 | return t 25 | } 26 | 27 | func (t *testFieldTree) Method4() testFieldTree { 28 | return *t 29 | } 30 | 31 | func Test_createFieldTree(t *testing.T) { 32 | testTable := []struct { 33 | name string 34 | structOrPtr interface{} 35 | wantFields []string 36 | wantErr bool 37 | }{ 38 | { 39 | name: "Detects methods attached via pointer receiver", 40 | structOrPtr: &testFieldTree{}, 41 | wantFields: []string{ 42 | ".Method1", 43 | ".Method2.Field1", 44 | ".Method3.Method1", 45 | }, 46 | }, 47 | } 48 | 49 | for _, tt := range testTable { 50 | t.Run(tt.name, func(t *testing.T) { 51 | fieldTree, err := createFieldTree(tt.structOrPtr) 52 | if (err != nil) != tt.wantErr { 53 | t.Errorf("createFieldTree() error = %v, wantErr %v", err, tt.wantErr) 54 | return 55 | } 56 | 57 | for _, field := range tt.wantFields { 58 | name := strings.TrimPrefix(field, ".") 59 | node := fieldTree.FindPath(strings.Split(name, ".")) 60 | if node == nil { 61 | t.Errorf("createFieldTree() field %q not found", field) 62 | } 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | ) 10 | 11 | type RenderProcess struct { 12 | Targets []string 13 | Template *template.Template 14 | } 15 | 16 | type RenderOption func(p *RenderProcess) 17 | 18 | // WithName copies the Template's default parse.Tree and adds it back 19 | // to the Template under the given name, effectively aliasing the Template. 20 | func WithName(name string) RenderOption { 21 | return func(p *RenderProcess) { 22 | p.Template = template.Must(p.Template.AddParseTree(name, p.Template.Tree.Copy())) 23 | } 24 | } 25 | 26 | // WithTarget sets the render Target to the given Template name. 27 | func WithTarget(target ...string) RenderOption { 28 | return func(p *RenderProcess) { 29 | p.Targets = append(p.Targets, target...) 30 | } 31 | } 32 | 33 | // WithFuncs appends the given Template.FuncMap to the Template's internal 34 | // func map. These functions become available in the Template during execution 35 | func WithFuncs(funcs template.FuncMap) RenderOption { 36 | return func(p *RenderProcess) { 37 | p.Template = p.Template.Funcs(funcs) 38 | } 39 | } 40 | 41 | func (tmpl *managedTemplate[T]) Render(wr io.Writer, data T, opts ...RenderOption) error { 42 | tmpl.mu.RLock() 43 | t, err := tmpl.template.Clone() 44 | tmpl.mu.RUnlock() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // defer a panic boundary to catch errors thrown by any of the 50 | // given visitor functions 51 | defer func() { 52 | if r := recover(); r != nil { 53 | switch t := r.(type) { 54 | case string: 55 | err = errors.New(t) 56 | case error: 57 | err = t 58 | default: 59 | err = fmt.Errorf("recovered panic during Template render option: %v", t) 60 | } 61 | } 62 | }() 63 | 64 | p := &RenderProcess{ 65 | Template: t, 66 | Targets: []string{}, 67 | } 68 | 69 | for _, opt := range opts { 70 | opt(p) 71 | } 72 | 73 | // render the default template if no targets are provided. 74 | if len(p.Targets) == 0 { 75 | p.Targets = append(p.Targets, t.Tree.ParseName) 76 | } 77 | 78 | buf := bytes.Buffer{} 79 | for _, target := range p.Targets { 80 | if err := p.Template.ExecuteTemplate(&buf, target, data); err != nil { 81 | return err 82 | } 83 | } 84 | 85 | _, err = wr.Write(buf.Bytes()) 86 | return err 87 | } 88 | 89 | func (tmpl *managedTemplate[T]) RenderToChan(ch chan string, data T, opts ...RenderOption) error { 90 | buf := bytes.Buffer{} 91 | err := tmpl.Render(&buf, data, opts...) 92 | if err != nil { 93 | return err 94 | } 95 | ch <- buf.String() 96 | return nil 97 | } 98 | 99 | func (tmpl *managedTemplate[T]) RenderToString(data T, opts ...RenderOption) (string, error) { 100 | buf := bytes.Buffer{} 101 | err := tmpl.Render(&buf, data, opts...) 102 | if err != nil { 103 | return "", err 104 | } 105 | return buf.String(), nil 106 | } 107 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "sync" 7 | ) 8 | 9 | // TemplateProvider is a struct type that returns its corresponding template text. 10 | type TemplateProvider interface { 11 | TemplateText() string 12 | } 13 | 14 | type Template[T TemplateProvider] interface { 15 | // Render can be used to execute the internal template. 16 | Render(w io.Writer, data T, opts ...RenderOption) error 17 | // RenderToChan can be used to execute the internal template and write the result to a channel. 18 | RenderToChan(ch chan string, data T, opts ...RenderOption) error 19 | // RenderToString can be used to execute the internal template and return the result as a string. 20 | RenderToString(data T, opts ...RenderOption) (string, error) 21 | } 22 | 23 | // managedTemplate represents a loaded and compiled tmpl file 24 | type managedTemplate[T TemplateProvider] struct { 25 | // mu is the mutex used to write to the underlying template 26 | mu *sync.RWMutex 27 | // template is the compiled Go template 28 | template *template.Template 29 | } 30 | -------------------------------------------------------------------------------- /testdata/compiler_test.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .Title }} 5 | 10 | {{ range .Scripts -}} 11 | {{- template "script" . -}} 12 | {{ end -}} 13 | 14 | 15 |
16 | {{ template "Content" .Content }} 17 |
18 | 19 | {{ template "form" . }} 20 | 21 |
22 |
{{ testFuncMap }}
23 |
24 | 25 | 26 | {{ define "form" }} 27 |
28 | {{ end }} -------------------------------------------------------------------------------- /testdata/structs.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import "html/template" 4 | 5 | type TextComponent struct { 6 | Text template.HTML 7 | } 8 | 9 | func (*TextComponent) TemplateText() string { 10 | return "{{.Text}}" 11 | } 12 | 13 | // NestedTemplateProvider does not implement TemplateProvider but has child fields that do. 14 | type NestedTemplateProvider struct { 15 | Text TextComponent `tmpl:"text"` 16 | } 17 | 18 | // DeeplyNestedTemplateProvider is a TemplateProvider that references a template provided by 19 | // a type that is deeply nested within a struct field. 20 | type DeeplyNestedTemplateProvider struct { 21 | Nested NestedTemplateProvider 22 | } 23 | 24 | func (*DeeplyNestedTemplateProvider) TemplateText() string { 25 | return `{{ template "text" .Nested.Text }}` 26 | } 27 | 28 | type DeeplyNestedTemplateProviderSlice struct { 29 | Nested []NestedTemplateProvider 30 | } 31 | 32 | func (*DeeplyNestedTemplateProviderSlice) TemplateText() string { 33 | return `{{range .Nested}}{{ template "text" .Text }}{{end}}` 34 | } 35 | 36 | type ScriptComponent struct { 37 | Source string 38 | } 39 | 40 | func (*ScriptComponent) TemplateText() string { 41 | return "" 42 | } 43 | 44 | type UndefinedTemplate struct{} 45 | 46 | func (*UndefinedTemplate) TemplateText() string { 47 | return `{{ template "undefined" }}` 48 | } 49 | 50 | type UndefinedRange struct{} 51 | 52 | func (*UndefinedRange) TemplateText() string { 53 | return `{{ range .UndList }}{{ end }}` 54 | } 55 | 56 | type DefinedRange struct { 57 | DefList []string 58 | } 59 | 60 | func (*DefinedRange) TemplateText() string { 61 | return `{{ range .DefList }}{{ . }}{{ end }}` 62 | } 63 | 64 | type StructRange struct { 65 | DefList []struct { 66 | DefField string 67 | } 68 | } 69 | 70 | func (*StructRange) TemplateText() string { 71 | return `{{ range .DefList }}{{ .DefField }}{{ end }}` 72 | } 73 | 74 | type NamedStruct struct { 75 | DefField string 76 | } 77 | 78 | type NamedStructRange struct { 79 | NamedStructs []NamedStruct 80 | } 81 | 82 | func (*NamedStructRange) TemplateText() string { 83 | return `{{ range .NamedStructs }}{{ .DefField }}{{ end }}` 84 | } 85 | 86 | type EmbeddedStruct struct { 87 | DefField string 88 | } 89 | 90 | type EmbeddedField struct { 91 | EmbeddedStruct 92 | } 93 | 94 | func (*EmbeddedField) TemplateText() string { 95 | return `{{ .DefField }}` 96 | } 97 | 98 | type DefinedField struct { 99 | DefField string 100 | } 101 | 102 | func (*DefinedField) TemplateText() string { 103 | return `{{ .DefField }}` 104 | } 105 | 106 | type DefinedNestedField struct { 107 | Nested DefinedField 108 | } 109 | 110 | func (*DefinedNestedField) TemplateText() string { 111 | return `{{ .Nested.DefField }}` 112 | } 113 | 114 | type UndefinedField struct{} 115 | 116 | func (*UndefinedField) TemplateText() string { 117 | return `{{ .UndField }}` 118 | } 119 | 120 | type UndefinedNestedField struct { 121 | Nested UndefinedField 122 | } 123 | 124 | func (*UndefinedNestedField) TemplateText() string { 125 | return `{{ .Nested.UndField }}` 126 | } 127 | 128 | type UndefinedIf struct{} 129 | 130 | func (*UndefinedIf) TemplateText() string { 131 | return `{{ if .UndIf }}{{ end }}` 132 | } 133 | 134 | type DefinedIf struct { 135 | DefIf bool 136 | Message string 137 | } 138 | 139 | func (*DefinedIf) TemplateText() string { 140 | return `{{ if .DefIf }}{{ .Message }}{{ end }}` 141 | } 142 | 143 | type AnyTypeIf struct { 144 | DefIf any 145 | } 146 | 147 | func (*AnyTypeIf) TemplateText() string { 148 | return `{{ if .DefIf }}{{ end }}` 149 | } 150 | 151 | type PipelineIf struct { 152 | DefInt int 153 | Message string 154 | } 155 | 156 | func (*PipelineIf) TemplateText() string { 157 | return `{{ if eq .DefInt 1 }}{{.Message}}{{ end }}` 158 | } 159 | 160 | // Tests multiple levels of embedded templates 161 | 162 | type LevelTwoEmbed struct { 163 | DefField string 164 | } 165 | 166 | func (*LevelTwoEmbed) TemplateText() string { 167 | return `{{ .DefField }}` 168 | } 169 | 170 | type LevelOneEmbed struct { 171 | LevelTwoEmbed `tmpl:"two"` 172 | } 173 | 174 | func (*LevelOneEmbed) TemplateText() string { 175 | return `{{ template "two" .}}` 176 | } 177 | 178 | type MultiLevelEmbeds struct { 179 | LevelOneEmbed `tmpl:"one"` 180 | } 181 | 182 | func (*MultiLevelEmbeds) TemplateText() string { 183 | return `{{ template "one" . }}` 184 | } 185 | 186 | type Child struct { 187 | DefField string 188 | } 189 | 190 | func (*Child) TemplateText() string { 191 | return `{{ .DefField }}` 192 | } 193 | 194 | type Parent struct { 195 | N Child `tmpl:"nested"` 196 | } 197 | 198 | func (*Parent) TemplateText() string { 199 | return `{{ template "nested" .N }}` 200 | } 201 | 202 | type NoPipeline struct { 203 | LevelOneEmbed `tmpl:"one"` 204 | } 205 | 206 | func (*NoPipeline) TemplateText() string { 207 | return `{{ template "one" }}` 208 | } 209 | 210 | type Outlet struct { 211 | Layout `tmpl:"layout"` 212 | 213 | Content string 214 | } 215 | 216 | func (*Outlet) TemplateText() string { 217 | return `{{ .Content }}` 218 | } 219 | 220 | type Layout struct{} 221 | 222 | func (*Layout) TemplateText() string { 223 | return `{{ template "outlet" . }}` 224 | } 225 | 226 | type OutletWithNested struct { 227 | Layout `tmpl:"layout"` 228 | LevelOneEmbed `tmpl:"one"` 229 | } 230 | 231 | func (*OutletWithNested) TemplateText() string { 232 | return `{{ template "one" . }}` 233 | } 234 | 235 | type LayoutWithNested struct { 236 | DefinedField `tmpl:"nested"` 237 | } 238 | 239 | func (*LayoutWithNested) TemplateText() string { 240 | return `{{ template "nested" . }}\n{{template "outlet" . }}` 241 | } 242 | 243 | type OutletWithNestedLayout struct { 244 | LayoutWithNested `tmpl:"layout"` 245 | 246 | Content string 247 | } 248 | 249 | func (*OutletWithNestedLayout) TemplateText() string { 250 | return `{{ .Content }}` 251 | } 252 | 253 | type IfWithinRange struct { 254 | DefList []DefinedIf 255 | } 256 | 257 | func (*IfWithinRange) TemplateText() string { 258 | return `{{ range .DefList }}{{ if .DefIf }}{{ .Message }}{{ end }}{{ end }}` 259 | } 260 | 261 | type StructTwo struct { 262 | DefField string 263 | } 264 | 265 | type StructOne struct { 266 | ListTwo []StructTwo 267 | } 268 | 269 | type StructRangeWithinRange struct { 270 | ListOne []StructOne 271 | } 272 | 273 | func (*StructRangeWithinRange) TemplateText() string { 274 | return `{{ range .ListOne }}{{ range .ListTwo }}{{ .DefField }}{{ end }}{{ end }}` 275 | } 276 | 277 | type DollarSignWithinRange struct { 278 | DefStr string 279 | DefList []string 280 | } 281 | 282 | func (*DollarSignWithinRange) TemplateText() string { 283 | return `{{ range .DefList }}{{ $.DefStr }}{{ end }}` 284 | } 285 | 286 | type DollarSignWithinIfWithinRange struct { 287 | DefStr string 288 | DefList []string 289 | } 290 | 291 | func (*DollarSignWithinIfWithinRange) TemplateText() string { 292 | return `{{ range .DefList }}{{ if eq . $.DefStr }}PASS{{else}}FAIL{{ end }}{{ end }}` 293 | } 294 | -------------------------------------------------------------------------------- /testdata/templates/watch_test.tmpl.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tylermmorton/tmpl/ac1621d7edfb8c3e4d2467652035bf7e8294d4e5/testdata/templates/watch_test.tmpl.html -------------------------------------------------------------------------------- /traverse.go: -------------------------------------------------------------------------------- 1 | package tmpl 2 | 3 | import "text/template/parse" 4 | 5 | // Visitor is a function that visits nodes in a parse.Tree traversal 6 | type Visitor = func(parse.Node) 7 | 8 | // Traverse is a depth-first traversal utility 9 | // for all nodes in a text/template/parse.Tree 10 | func Traverse(cur parse.Node, visitors ...Visitor) { 11 | for _, visitor := range visitors { 12 | visitor(cur) 13 | } 14 | 15 | switch node := cur.(type) { 16 | case *parse.ActionNode: 17 | if node.Pipe != nil { 18 | Traverse(node.Pipe, visitors...) 19 | } 20 | case *parse.BoolNode: 21 | case *parse.BranchNode: 22 | if node.Pipe != nil { 23 | Traverse(node.Pipe, visitors...) 24 | } 25 | if node.List != nil { 26 | Traverse(node.List, visitors...) 27 | } 28 | if node.ElseList != nil { 29 | Traverse(node.ElseList, visitors...) 30 | } 31 | case *parse.BreakNode: 32 | case *parse.ChainNode: 33 | case *parse.CommandNode: 34 | if node.Args != nil { 35 | for _, arg := range node.Args { 36 | Traverse(arg, visitors...) 37 | } 38 | } 39 | case *parse.CommentNode: 40 | case *parse.ContinueNode: 41 | case *parse.DotNode: 42 | case *parse.FieldNode: 43 | case *parse.IdentifierNode: 44 | case *parse.IfNode: 45 | Traverse(&node.BranchNode, visitors...) 46 | case *parse.ListNode: 47 | if node.Nodes != nil { 48 | for _, child := range node.Nodes { 49 | Traverse(child, visitors...) 50 | } 51 | } 52 | case *parse.NilNode: 53 | case *parse.NumberNode: 54 | case *parse.PipeNode: 55 | if node.Cmds != nil { 56 | for _, cmd := range node.Cmds { 57 | Traverse(cmd, visitors...) 58 | } 59 | } 60 | if node.Decl != nil { 61 | for _, decl := range node.Decl { 62 | Traverse(decl, visitors...) 63 | } 64 | } 65 | case *parse.RangeNode: 66 | Traverse(&node.BranchNode, visitors...) 67 | case *parse.StringNode: 68 | case *parse.TemplateNode: 69 | if node.Pipe != nil { 70 | Traverse(node.Pipe, visitors...) 71 | } 72 | case *parse.TextNode: 73 | case *parse.VariableNode: 74 | case *parse.WithNode: 75 | Traverse(&node.BranchNode, visitors...) 76 | } 77 | } 78 | --------------------------------------------------------------------------------