mirror of
https://github.com/go-gitea/gitea
synced 2024-12-27 22:54:32 +01:00
50a72e7a83
The idea is to use a Layered Asset File-system (modules/assetfs/layered.go) For example: when there are 2 layers: "custom", "builtin", when access to asset "my/page.tmpl", the Layered Asset File-system will first try to use "custom" assets, if not found, then use "builtin" assets. This approach will hugely simplify a lot of code, make them testable. Other changes: * Simplify the AssetsHandlerFunc code * Simplify the `gitea embedded` sub-command code --------- Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
270 lines
8.4 KiB
Go
270 lines
8.4 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"
|
|
)
|
|
|
|
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 {
|
|
extSuffix := ".tmpl"
|
|
tmpls := template.New("")
|
|
assets := AssetFS()
|
|
files, err := ListWebTemplateAssetNames(assets)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, file := range files {
|
|
if !strings.HasSuffix(file, extSuffix) {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(file, extSuffix)
|
|
tmpl := tmpls.New(filepath.ToSlash(name))
|
|
for _, fm := range NewFuncMap() {
|
|
tmpl.Funcs(fm)
|
|
}
|
|
buf, err := assets.ReadFile(file)
|
|
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 {
|
|
wrapFatal(handleNotDefinedPanicError(err))
|
|
wrapFatal(handleUnexpected(err))
|
|
wrapFatal(handleExpectedEnd(err))
|
|
wrapFatal(handleGenericTemplateError(err))
|
|
log.Fatal("HTMLRenderer error: %v", err)
|
|
}
|
|
if !setting.IsProd {
|
|
go AssetFS().WatchLocalChanges(ctx, 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 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 := fmt.Sprintf("%s (provided by %s)", templateName, AssetFS().GetFileLayerName(templateName+".tmpl"))
|
|
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 := fmt.Sprintf("%s (provided by %s)", templateName, AssetFS().GetFileLayerName(templateName+".tmpl"))
|
|
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 := fmt.Sprintf("%s (provided by %s)", templateName, AssetFS().GetFileLayerName(templateName+".tmpl"))
|
|
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 := fmt.Sprintf("%s (provided by %s)", templateName, AssetFS().GetFileLayerName(templateName+".tmpl"))
|
|
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 := AssetFS().ReadFile(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()
|
|
}
|