├── .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 |
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 | "",
73 | "testFunc result
",
74 | " ",
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 | "
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 |
--------------------------------------------------------------------------------