Skip to content

Commit 722dab5

Browse files
wxiaoguangsilverwindwolfogreGiteaBot
authored
Make HTML template functions support context (#24056)
# Background Golang template is not friendly for large projects, and Golang template team is quite slow, related: * `https://github.com/golang/go/issues/54450` Without upstream support, we can also have our solution to make HTML template functions support context. It helps a lot, the above Golang template issue `#54450` explains a lot: 1. It makes `{{Locale.Tr}}` could be used in any template, without passing unclear `(dict "root" . )` anymore. 2. More and more functions need `context`, like `avatar`, etc, we do not need to do `(dict "Context" $.Context)` anymore. 3. Many request-related functions could be shared by parent&children templates, like "user setting" / "system setting" See the test `TestScopedTemplateSetFuncMap`, one template set, two `Execute` calls with different `CtxFunc`. # The Solution Instead of waiting for upstream, this PR re-uses the escaped HTML template trees, use `AddParseTree` to add related templates/trees to a new template instance, then the new template instance can have its own FuncMap , the function calls in the template trees will always use the new template's FuncMap. `template.New` / `template.AddParseTree` / `adding-FuncMap` are all quite fast, so the performance is not affected. The details: 1. Make a new `html/template/Template` for `all` templates 2. Add template code to the `all` template 3. Freeze the `all` template, reset its exec func map, it shouldn't execute any template. 4. When a router wants to render a template by its `name` 1. Find the `name` in `all` 2. Find all its related sub templates 3. Escape all related templates (just like what the html template package does) 4. Add the escaped parse-trees of related templates into a new (scoped) `text/template/Template` 5. Add context-related func map into the new (scoped) text template 6. Execute the new (scoped) text template 7. To improve performance, the escaped templates are cached to `template sets` # FAQ ## There is a `unsafe` call, is this PR unsafe? This PR is safe. Golang has strict language definition, it's safe to do so: https://pkg.go.dev/unsafe#Pointer (1) Conversion of a *T1 to Pointer to *T2 ## What if Golang template supports such feature in the future? The public structs/interfaces/functions introduced by this PR is quite simple, the code of `HTMLRender` is not changed too much. It's very easy to switch to the official mechanism if there would be one. ## Does this PR change the template execution behavior? No, see the tests (welcome to design more tests if it's necessary) --------- Co-authored-by: silverwind <[email protected]> Co-authored-by: Jason Song <[email protected]> Co-authored-by: Giteabot <[email protected]>
1 parent de2268f commit 722dab5

File tree

5 files changed

+351
-16
lines changed

5 files changed

+351
-16
lines changed

modules/context/context.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const CookieNameFlash = "gitea_flash"
4747

4848
// Render represents a template render
4949
type Render interface {
50-
TemplateLookup(tmpl string) (*template.Template, error)
50+
TemplateLookup(tmpl string) (templates.TemplateExecutor, error)
5151
HTML(w io.Writer, status int, name string, data interface{}) error
5252
}
5353

modules/templates/htmlrenderer.go

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"context"
1010
"errors"
1111
"fmt"
12-
"html/template"
1312
"io"
1413
"net/http"
1514
"path/filepath"
@@ -22,13 +21,16 @@ import (
2221
"code.gitea.io/gitea/modules/assetfs"
2322
"code.gitea.io/gitea/modules/log"
2423
"code.gitea.io/gitea/modules/setting"
24+
"code.gitea.io/gitea/modules/templates/scopedtmpl"
2525
"code.gitea.io/gitea/modules/util"
2626
)
2727

2828
var rendererKey interface{} = "templatesHtmlRenderer"
2929

30+
type TemplateExecutor scopedtmpl.TemplateExecutor
31+
3032
type HTMLRender struct {
31-
templates atomic.Pointer[template.Template]
33+
templates atomic.Pointer[scopedtmpl.ScopedTemplate]
3234
}
3335

3436
var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
@@ -47,22 +49,20 @@ func (h *HTMLRender) HTML(w io.Writer, status int, name string, data interface{}
4749
return t.Execute(w, data)
4850
}
4951

50-
func (h *HTMLRender) TemplateLookup(name string) (*template.Template, error) {
52+
func (h *HTMLRender) TemplateLookup(name string) (TemplateExecutor, error) {
5153
tmpls := h.templates.Load()
5254
if tmpls == nil {
5355
return nil, ErrTemplateNotInitialized
5456
}
55-
tmpl := tmpls.Lookup(name)
56-
if tmpl == nil {
57-
return nil, util.ErrNotExist
58-
}
59-
return tmpl, nil
57+
58+
return tmpls.Executor(name, NewFuncMap()[0])
6059
}
6160

6261
func (h *HTMLRender) CompileTemplates() error {
63-
extSuffix := ".tmpl"
64-
tmpls := template.New("")
6562
assets := AssetFS()
63+
extSuffix := ".tmpl"
64+
tmpls := scopedtmpl.NewScopedTemplate()
65+
tmpls.Funcs(NewFuncMap()[0])
6666
files, err := ListWebTemplateAssetNames(assets)
6767
if err != nil {
6868
return nil
@@ -73,9 +73,6 @@ func (h *HTMLRender) CompileTemplates() error {
7373
}
7474
name := strings.TrimSuffix(file, extSuffix)
7575
tmpl := tmpls.New(filepath.ToSlash(name))
76-
for _, fm := range NewFuncMap() {
77-
tmpl.Funcs(fm)
78-
}
7976
buf, err := assets.ReadFile(file)
8077
if err != nil {
8178
return err
@@ -84,6 +81,7 @@ func (h *HTMLRender) CompileTemplates() error {
8481
return err
8582
}
8683
}
84+
tmpls.Freeze()
8785
h.templates.Store(tmpls)
8886
return nil
8987
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package scopedtmpl
5+
6+
import (
7+
"fmt"
8+
"html/template"
9+
"io"
10+
"reflect"
11+
"sync"
12+
texttemplate "text/template"
13+
"text/template/parse"
14+
"unsafe"
15+
)
16+
17+
type TemplateExecutor interface {
18+
Execute(wr io.Writer, data interface{}) error
19+
}
20+
21+
type ScopedTemplate struct {
22+
all *template.Template
23+
parseFuncs template.FuncMap // this func map is only used for parsing templates
24+
frozen bool
25+
26+
scopedMu sync.RWMutex
27+
scopedTemplateSets map[string]*scopedTemplateSet
28+
}
29+
30+
func NewScopedTemplate() *ScopedTemplate {
31+
return &ScopedTemplate{
32+
all: template.New(""),
33+
parseFuncs: template.FuncMap{},
34+
scopedTemplateSets: map[string]*scopedTemplateSet{},
35+
}
36+
}
37+
38+
func (t *ScopedTemplate) Funcs(funcMap template.FuncMap) {
39+
if t.frozen {
40+
panic("cannot add new functions to frozen template set")
41+
}
42+
t.all.Funcs(funcMap)
43+
for k, v := range funcMap {
44+
t.parseFuncs[k] = v
45+
}
46+
}
47+
48+
func (t *ScopedTemplate) New(name string) *template.Template {
49+
if t.frozen {
50+
panic("cannot add new template to frozen template set")
51+
}
52+
return t.all.New(name)
53+
}
54+
55+
func (t *ScopedTemplate) Freeze() {
56+
t.frozen = true
57+
// reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping
58+
m := template.FuncMap{}
59+
for k := range t.parseFuncs {
60+
m[k] = func(v ...any) any { return nil }
61+
}
62+
t.all.Funcs(m)
63+
}
64+
65+
func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
66+
t.scopedMu.RLock()
67+
scopedTmplSet, ok := t.scopedTemplateSets[name]
68+
t.scopedMu.RUnlock()
69+
70+
if !ok {
71+
var err error
72+
t.scopedMu.Lock()
73+
if scopedTmplSet, ok = t.scopedTemplateSets[name]; !ok {
74+
if scopedTmplSet, err = newScopedTemplateSet(t.all, name); err == nil {
75+
t.scopedTemplateSets[name] = scopedTmplSet
76+
}
77+
}
78+
t.scopedMu.Unlock()
79+
if err != nil {
80+
return nil, err
81+
}
82+
}
83+
84+
if scopedTmplSet == nil {
85+
return nil, fmt.Errorf("template %s not found", name)
86+
}
87+
return scopedTmplSet.newExecutor(funcMap), nil
88+
}
89+
90+
type scopedTemplateSet struct {
91+
name string
92+
htmlTemplates map[string]*template.Template
93+
textTemplates map[string]*texttemplate.Template
94+
execFuncs map[string]reflect.Value
95+
}
96+
97+
func escapeTemplate(t *template.Template) error {
98+
// force the Golang HTML template to complete the escaping work
99+
err := t.Execute(io.Discard, nil)
100+
if _, ok := err.(*template.Error); ok {
101+
return err
102+
}
103+
return nil
104+
}
105+
106+
//nolint:unused
107+
type htmlTemplate struct {
108+
escapeErr error
109+
text *texttemplate.Template
110+
}
111+
112+
//nolint:unused
113+
type textTemplateCommon struct {
114+
tmpl map[string]*template.Template // Map from name to defined templates.
115+
muTmpl sync.RWMutex // protects tmpl
116+
option struct {
117+
missingKey int
118+
}
119+
muFuncs sync.RWMutex // protects parseFuncs and execFuncs
120+
parseFuncs texttemplate.FuncMap
121+
execFuncs map[string]reflect.Value
122+
}
123+
124+
//nolint:unused
125+
type textTemplate struct {
126+
name string
127+
*parse.Tree
128+
*textTemplateCommon
129+
leftDelim string
130+
rightDelim string
131+
}
132+
133+
func ptr[T, P any](ptr *P) *T {
134+
// https://pkg.go.dev/unsafe#Pointer
135+
// (1) Conversion of a *T1 to Pointer to *T2.
136+
// Provided that T2 is no larger than T1 and that the two share an equivalent memory layout,
137+
// this conversion allows reinterpreting data of one type as data of another type.
138+
return (*T)(unsafe.Pointer(ptr))
139+
}
140+
141+
func newScopedTemplateSet(all *template.Template, name string) (*scopedTemplateSet, error) {
142+
targetTmpl := all.Lookup(name)
143+
if targetTmpl == nil {
144+
return nil, fmt.Errorf("template %q not found", name)
145+
}
146+
if err := escapeTemplate(targetTmpl); err != nil {
147+
return nil, fmt.Errorf("template %q has an error when escaping: %w", name, err)
148+
}
149+
150+
ts := &scopedTemplateSet{
151+
name: name,
152+
htmlTemplates: map[string]*template.Template{},
153+
textTemplates: map[string]*texttemplate.Template{},
154+
}
155+
156+
htmlTmpl := ptr[htmlTemplate](all)
157+
textTmpl := htmlTmpl.text
158+
textTmplPtr := ptr[textTemplate](textTmpl)
159+
160+
textTmplPtr.muFuncs.Lock()
161+
ts.execFuncs = map[string]reflect.Value{}
162+
for k, v := range textTmplPtr.execFuncs {
163+
ts.execFuncs[k] = v
164+
}
165+
textTmplPtr.muFuncs.Unlock()
166+
167+
var collectTemplates func(nodes []parse.Node)
168+
var collectErr error // only need to collect the one error
169+
collectTemplates = func(nodes []parse.Node) {
170+
for _, node := range nodes {
171+
if node.Type() == parse.NodeTemplate {
172+
nodeTemplate := node.(*parse.TemplateNode)
173+
subName := nodeTemplate.Name
174+
if ts.htmlTemplates[subName] == nil {
175+
subTmpl := all.Lookup(subName)
176+
if subTmpl == nil {
177+
// HTML template will add some internal templates like "$delimDoubleQuote" into the text template
178+
ts.textTemplates[subName] = textTmpl.Lookup(subName)
179+
} else if subTmpl.Tree == nil || subTmpl.Tree.Root == nil {
180+
collectErr = fmt.Errorf("template %q has no tree, it's usually caused by broken templates", subName)
181+
} else {
182+
ts.htmlTemplates[subName] = subTmpl
183+
if err := escapeTemplate(subTmpl); err != nil {
184+
collectErr = fmt.Errorf("template %q has an error when escaping: %w", subName, err)
185+
return
186+
}
187+
collectTemplates(subTmpl.Tree.Root.Nodes)
188+
}
189+
}
190+
} else if node.Type() == parse.NodeList {
191+
nodeList := node.(*parse.ListNode)
192+
collectTemplates(nodeList.Nodes)
193+
} else if node.Type() == parse.NodeIf {
194+
nodeIf := node.(*parse.IfNode)
195+
collectTemplates(nodeIf.BranchNode.List.Nodes)
196+
if nodeIf.BranchNode.ElseList != nil {
197+
collectTemplates(nodeIf.BranchNode.ElseList.Nodes)
198+
}
199+
} else if node.Type() == parse.NodeRange {
200+
nodeRange := node.(*parse.RangeNode)
201+
collectTemplates(nodeRange.BranchNode.List.Nodes)
202+
if nodeRange.BranchNode.ElseList != nil {
203+
collectTemplates(nodeRange.BranchNode.ElseList.Nodes)
204+
}
205+
} else if node.Type() == parse.NodeWith {
206+
nodeWith := node.(*parse.WithNode)
207+
collectTemplates(nodeWith.BranchNode.List.Nodes)
208+
if nodeWith.BranchNode.ElseList != nil {
209+
collectTemplates(nodeWith.BranchNode.ElseList.Nodes)
210+
}
211+
}
212+
}
213+
}
214+
ts.htmlTemplates[name] = targetTmpl
215+
collectTemplates(targetTmpl.Tree.Root.Nodes)
216+
return ts, collectErr
217+
}
218+
219+
func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor {
220+
tmpl := texttemplate.New("")
221+
tmplPtr := ptr[textTemplate](tmpl)
222+
tmplPtr.execFuncs = map[string]reflect.Value{}
223+
for k, v := range ts.execFuncs {
224+
tmplPtr.execFuncs[k] = v
225+
}
226+
if funcMap != nil {
227+
tmpl.Funcs(funcMap)
228+
}
229+
// after escapeTemplate, the html templates are also escaped text templates, so it could be added to the text template directly
230+
for _, t := range ts.htmlTemplates {
231+
_, _ = tmpl.AddParseTree(t.Name(), t.Tree)
232+
}
233+
for _, t := range ts.textTemplates {
234+
_, _ = tmpl.AddParseTree(t.Name(), t.Tree)
235+
}
236+
237+
// now the text template has all necessary escaped templates, so we can safely execute, just like what the html template does
238+
return tmpl.Lookup(ts.name)
239+
}

0 commit comments

Comments
 (0)