gitea/modules/templates/htmlrenderer.go
wxiaoguang fdbd646113
Group template helper functions, remove Printf, improve template error messages (#23982)
Follow #23328 


Major changes:

* Group the function in `templates/help.go` by their purposes. It could
make future work easier.
* Remove the `Printf` helper function, there is already a builtin
`printf`.
* Remove `DiffStatsWidth`, replace with `Eval` in template
* Rename the `NewTextFuncMap` to `mailSubjectTextFuncMap`, it's for
subject text template only, no need to make it support HTML functions.


----

And fine tune template error messages, to make it more friendly to
developers and users.


![image](https://user-images.githubusercontent.com/2114189/230714245-4fd202d1-2b25-41b2-8be5-03c5fee45091.png)


![image](https://user-images.githubusercontent.com/2114189/230714277-66783577-2a03-49d5-8e8c-ceba5e07a2d4.png)

---------

Co-authored-by: silverwind <me@silverwind.io>
2023-04-08 21:15:22 +08:00

296 lines
8.5 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
texttemplate "text/template"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/watcher"
)
var (
rendererKey interface{} = "templatesHtmlRenderer"
templateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
notDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): function "(.*)" not defined`)
unexpectedError = regexp.MustCompile(`^template: (.*):([0-9]+): unexpected "(.*)" in operand`)
expectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): expected end; found (.*)`)
)
type HTMLRender struct {
templates atomic.Pointer[template.Template]
}
var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
func (h *HTMLRender) HTML(w io.Writer, status int, name string, data interface{}) error {
if respWriter, ok := w.(http.ResponseWriter); ok {
if respWriter.Header().Get("Content-Type") == "" {
respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
}
respWriter.WriteHeader(status)
}
t, err := h.TemplateLookup(name)
if err != nil {
return texttemplate.ExecError{Name: name, Err: err}
}
return t.Execute(w, data)
}
func (h *HTMLRender) TemplateLookup(name string) (*template.Template, error) {
tmpls := h.templates.Load()
if tmpls == nil {
return nil, ErrTemplateNotInitialized
}
tmpl := tmpls.Lookup(name)
if tmpl == nil {
return nil, util.ErrNotExist
}
return tmpl, nil
}
func (h *HTMLRender) CompileTemplates() error {
dirPrefix := "templates/"
tmpls := template.New("")
for _, path := range GetTemplateAssetNames() {
name := path[len(dirPrefix):]
name = strings.TrimSuffix(name, ".tmpl")
tmpl := tmpls.New(filepath.ToSlash(name))
for _, fm := range NewFuncMap() {
tmpl.Funcs(fm)
}
buf, err := GetAsset(path)
if err != nil {
return err
}
if _, err = tmpl.Parse(string(buf)); err != nil {
return err
}
}
h.templates.Store(tmpls)
return nil
}
// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
func HTMLRenderer(ctx context.Context) (context.Context, *HTMLRender) {
if renderer, ok := ctx.Value(rendererKey).(*HTMLRender); ok {
return ctx, renderer
}
rendererType := "static"
if !setting.IsProd {
rendererType = "auto-reloading"
}
log.Log(1, log.DEBUG, "Creating "+rendererType+" HTML Renderer")
renderer := &HTMLRender{}
if err := renderer.CompileTemplates(); err != nil {
handleFatalError(err)
}
if !setting.IsProd {
watcher.CreateWatcher(ctx, "HTML Templates", &watcher.CreateWatcherOpts{
PathsCallback: walkTemplateFiles,
BetweenCallback: func() {
if err := renderer.CompileTemplates(); err != nil {
log.Error("Template error: %v\n%s", err, log.Stack(2))
}
},
})
}
return context.WithValue(ctx, rendererKey, renderer), renderer
}
func handleFatalError(err error) {
wrapFatal(handleNotDefinedPanicError(err))
wrapFatal(handleUnexpected(err))
wrapFatal(handleExpectedEnd(err))
wrapFatal(handleGenericTemplateError(err))
}
func wrapFatal(format string, args []interface{}) {
if format == "" {
return
}
log.FatalWithSkip(1, format, args...)
}
func handleGenericTemplateError(err error) (string, []interface{}) {
groups := templateError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
templateName, lineNumberStr, message := groups[1], groups[2], groups[3]
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, "", -1)
return "PANIC: Unable to compile templates!\n%s in template file %s at line %d:\n\n%s\nStacktrace:\n\n%s", []interface{}{message, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
}
func handleNotDefinedPanicError(err error) (string, []interface{}) {
groups := notDefinedError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
templateName, lineNumberStr, functionName := groups[1], groups[2], groups[3]
functionName, _ = strconv.Unquote(`"` + functionName + `"`)
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, functionName, -1)
return "PANIC: Unable to compile templates!\nUndefined function %q in template file %s at line %d:\n\n%s", []interface{}{functionName, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
}
func handleUnexpected(err error) (string, []interface{}) {
groups := unexpectedError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, unexpected, -1)
return "PANIC: Unable to compile templates!\nUnexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
}
func handleExpectedEnd(err error) (string, []interface{}) {
groups := expectedEndError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return "", nil
}
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
if assetErr != nil {
return "", nil
}
lineNumber, _ := strconv.Atoi(lineNumberStr)
line := GetLineFromTemplate(templateName, lineNumber, unexpected, -1)
return "PANIC: Unable to compile templates!\nMissing end with unexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
}
const dashSeparator = "----------------------------------------------------------------------\n"
// GetLineFromTemplate returns a line from a template with some context
func GetLineFromTemplate(templateName string, targetLineNum int, target string, position int) string {
bs, err := GetAsset("templates/" + templateName + ".tmpl")
if err != nil {
return fmt.Sprintf("(unable to read template file: %v)", err)
}
sb := &strings.Builder{}
// Write the header
sb.WriteString(dashSeparator)
var lineBs []byte
// Iterate through the lines from the asset file to find the target line
for start, currentLineNum := 0, 1; currentLineNum <= targetLineNum && start < len(bs); currentLineNum++ {
// Find the next new line
end := bytes.IndexByte(bs[start:], '\n')
// adjust the end to be a direct pointer in to []byte
if end < 0 {
end = len(bs)
} else {
end += start
}
// set lineBs to the current line []byte
lineBs = bs[start:end]
// move start to after the current new line position
start = end + 1
// Write 2 preceding lines + the target line
if targetLineNum-currentLineNum < 3 {
_, _ = sb.Write(lineBs)
_ = sb.WriteByte('\n')
}
}
// FIXME: this algorithm could provide incorrect results and mislead the developers.
// For example: Undefined function "file" in template .....
// {{Func .file.Addition file.Deletion .file.Addition}}
// ^^^^ ^(the real error is here)
// The pointer is added to the first one, but the second one is the real incorrect one.
//
// If there is a provided target to look for in the line add a pointer to it
// e.g. ^^^^^^^
if target != "" {
targetPos := bytes.Index(lineBs, []byte(target))
if targetPos >= 0 {
position = targetPos
}
}
if position >= 0 {
// take the current line and replace preceding text with whitespace (except for tab)
for i := range lineBs[:position] {
if lineBs[i] != '\t' {
lineBs[i] = ' '
}
}
// write the preceding "space"
_, _ = sb.Write(lineBs[:position])
// Now write the ^^ pointer
targetLen := len(target)
if targetLen == 0 {
targetLen = 1
}
_, _ = sb.WriteString(strings.Repeat("^", targetLen))
_ = sb.WriteByte('\n')
}
// Finally write the footer
sb.WriteString(dashSeparator)
return sb.String()
}