Refactor template render (#36438)

This commit is contained in:
wxiaoguang
2026-01-24 13:11:49 +08:00
committed by GitHub
parent 47717d4435
commit 9de659437e
31 changed files with 475 additions and 459 deletions

View File

@@ -6,9 +6,7 @@ package assetfs
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
@@ -25,7 +23,7 @@ import (
// Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem
type Layer struct {
name string
fs http.FileSystem
fs fs.FS
localPath string
}
@@ -34,7 +32,7 @@ func (l *Layer) Name() string {
}
// Open opens the named file. The caller is responsible for closing the file.
func (l *Layer) Open(name string) (http.File, error) {
func (l *Layer) Open(name string) (fs.File, error) {
return l.fs.Open(name)
}
@@ -48,12 +46,12 @@ func Local(name, base string, sub ...string) *Layer {
panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err))
}
root := util.FilePathJoinAbs(base, sub...)
return &Layer{name: name, fs: http.Dir(root), localPath: root}
return &Layer{name: name, fs: os.DirFS(root), localPath: root}
}
// Bindata returns a new Layer with the given name, it serves files from the given bindata asset.
func Bindata(name string, fs fs.FS) *Layer {
return &Layer{name: name, fs: http.FS(fs)}
return &Layer{name: name, fs: fs}
}
// LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers.
@@ -69,7 +67,7 @@ func Layered(layers ...*Layer) *LayeredFS {
}
// Open opens the named file. The caller is responsible for closing the file.
func (l *LayeredFS) Open(name string) (http.File, error) {
func (l *LayeredFS) Open(name string) (fs.File, error) {
for _, layer := range l.layers {
f, err := layer.Open(name)
if err == nil || !os.IsNotExist(err) {
@@ -89,40 +87,34 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
name := util.PathJoinRel(elems...)
for _, layer := range l.layers {
f, err := layer.Open(name)
bs, err := fs.ReadFile(layer, name)
if os.IsNotExist(err) {
continue
} else if err != nil {
return nil, layer.name, err
}
bs, err := io.ReadAll(f)
_ = f.Close()
return bs, layer.name, err
}
return nil, "", fs.ErrNotExist
}
func shouldInclude(info fs.FileInfo, fileMode ...bool) bool {
if util.IsCommonHiddenFileName(info.Name()) {
func shouldInclude(dirEntry fs.DirEntry, fileMode ...bool) bool {
if util.IsCommonHiddenFileName(dirEntry.Name()) {
return false
}
if len(fileMode) == 0 {
return true
} else if len(fileMode) == 1 {
return fileMode[0] == !info.Mode().IsDir()
return fileMode[0] == !dirEntry.IsDir()
}
panic("too many arguments for fileMode in shouldInclude")
}
func readDir(layer *Layer, name string) ([]fs.FileInfo, error) {
f, err := layer.Open(name)
if os.IsNotExist(err) {
func readDirOptional(layer *Layer, name string) (entries []fs.DirEntry, err error) {
if entries, err = fs.ReadDir(layer, name); os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
defer f.Close()
return f.Readdir(-1)
return entries, err
}
// ListFiles lists files/directories in the given directory. The fileMode controls the returned files.
@@ -133,13 +125,13 @@ func readDir(layer *Layer, name string) ([]fs.FileInfo, error) {
func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) {
fileSet := make(container.Set[string])
for _, layer := range l.layers {
infos, err := readDir(layer, name)
entries, err := readDirOptional(layer, name)
if err != nil {
return nil, err
}
for _, info := range infos {
if shouldInclude(info, fileMode...) {
fileSet.Add(info.Name())
for _, entry := range entries {
if shouldInclude(entry, fileMode...) {
fileSet.Add(entry.Name())
}
}
}
@@ -163,16 +155,16 @@ func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, err
var list func(dir string) error
list = func(dir string) error {
for _, layer := range layers {
infos, err := readDir(layer, dir)
entries, err := readDirOptional(layer, dir)
if err != nil {
return err
}
for _, info := range infos {
path := util.PathJoinRelX(dir, info.Name())
if shouldInclude(info, fileMode...) {
for _, entry := range entries {
path := util.PathJoinRelX(dir, entry.Name())
if shouldInclude(entry, fileMode...) {
fileSet.Add(path)
}
if info.IsDir() {
if entry.IsDir() {
if err = list(path); err != nil {
return err
}

View File

@@ -36,7 +36,7 @@ func FileHandlerFunc() http.HandlerFunc {
resp.WriteHeader(http.StatusMethodNotAllowed)
return
}
handleRequest(resp, req, assetFS, req.URL.Path)
handleRequest(resp, req, http.FS(assetFS), req.URL.Path)
}
}

View File

@@ -4,9 +4,6 @@
package templates
import (
"slices"
"strings"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/setting"
)
@@ -18,23 +15,3 @@ func AssetFS() *assetfs.LayeredFS {
func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "templates")
}
func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
files, err := assets.ListAllFiles(".", true)
if err != nil {
return nil, err
}
return slices.DeleteFunc(files, func(file string) bool {
return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
}), nil
}
func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
files, err := assets.ListAllFiles(".", true)
if err != nil {
return nil, err
}
return slices.DeleteFunc(files, func(file string) bool {
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
}), nil
}

View File

@@ -25,8 +25,6 @@ import (
// NewFuncMap returns functions for injecting to templates
func NewFuncMap() template.FuncMap {
return map[string]any{
"ctx": func() any { return nil }, // template context function
"DumpVar": dumpVar,
"NIL": func() any { return nil },

View File

@@ -6,21 +6,18 @@ package templates
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
texttemplate "text/template"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates/scopedtmpl"
@@ -31,58 +28,27 @@ type TemplateExecutor scopedtmpl.TemplateExecutor
type TplName string
type HTMLRender struct {
type tmplRender struct {
templates atomic.Pointer[scopedtmpl.ScopedTemplate]
collectTemplateNames func() ([]string, error)
readTemplateContent func(name string) ([]byte, error)
}
var (
htmlRender *HTMLRender
htmlRenderOnce sync.Once
)
var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
func (h *HTMLRender) HTML(w io.Writer, status int, tplName TplName, data any, ctx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
name := string(tplName)
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, ctx)
if err != nil {
return texttemplate.ExecError{Name: name, Err: err}
}
return t.Execute(w, data)
func (h *tmplRender) Templates() *scopedtmpl.ScopedTemplate {
return h.templates.Load()
}
func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
tmpls := h.templates.Load()
if tmpls == nil {
return nil, ErrTemplateNotInitialized
}
m := NewFuncMap()
m["ctx"] = func() any { return ctx }
return tmpls.Executor(name, m)
}
func (h *HTMLRender) CompileTemplates() error {
assets := AssetFS()
extSuffix := ".tmpl"
func (h *tmplRender) recompileTemplates(dummyFuncMap template.FuncMap) error {
tmpls := scopedtmpl.NewScopedTemplate()
tmpls.Funcs(NewFuncMap())
files, err := ListWebTemplateAssetNames(assets)
tmpls.Funcs(dummyFuncMap)
names, err := h.collectTemplateNames()
if err != nil {
return nil
return err
}
for _, file := range files {
if !strings.HasSuffix(file, extSuffix) {
continue
}
name := strings.TrimSuffix(file, extSuffix)
for _, name := range names {
tmpl := tmpls.New(filepath.ToSlash(name))
buf, err := assets.ReadFile(file)
buf, err := h.readTemplateContent(name)
if err != nil {
return err
}
@@ -95,55 +61,20 @@ func (h *HTMLRender) CompileTemplates() error {
return nil
}
// HTMLRenderer init once and returns the globally shared html renderer
func HTMLRenderer() *HTMLRender {
htmlRenderOnce.Do(initHTMLRenderer)
return htmlRender
func ReloadAllTemplates() error {
return errors.Join(PageRendererReload(), MailRendererReload())
}
func ReloadHTMLTemplates() error {
log.Trace("Reloading HTML templates")
if err := htmlRender.CompileTemplates(); err != nil {
log.Error("Template error: %v\n%s", err, log.Stack(2))
return err
}
return nil
}
func initHTMLRenderer() {
rendererType := "static"
if !setting.IsProd {
rendererType = "auto-reloading"
}
log.Debug("Creating %s HTML Renderer", rendererType)
htmlRender = &HTMLRender{}
if err := htmlRender.CompileTemplates(); err != nil {
p := &templateErrorPrettier{assets: AssetFS()}
wrapTmplErrMsg(p.handleFuncNotDefinedError(err))
wrapTmplErrMsg(p.handleUnexpectedOperandError(err))
wrapTmplErrMsg(p.handleExpectedEndError(err))
wrapTmplErrMsg(p.handleGenericTemplateError(err))
wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err))
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
_ = ReloadHTMLTemplates()
})
}
}
func wrapTmplErrMsg(msg string) {
if msg == "" {
func processStartupTemplateError(err error) {
if err == nil {
return
}
if setting.IsProd {
if setting.IsProd || setting.IsInTesting {
// in prod mode, Gitea must have correct templates to run
log.Fatal("Gitea can't run with template errors: %s", msg)
log.Fatal("Gitea can't run with template errors: %v", err)
}
// in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded
log.Error("There are template errors but Gitea continues to run in dev mode: %s", msg)
log.Error("There are template errors but Gitea continues to run in dev mode: %v", err)
}
type templateErrorPrettier struct {

195
modules/templates/mail.go Normal file
View File

@@ -0,0 +1,195 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"io"
"regexp"
"slices"
"strings"
"sync"
texttmpl "text/template"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type MailRender struct {
TemplateNames []string
BodyTemplates struct {
HasTemplate func(name string) bool
ExecuteTemplate func(w io.Writer, name string, data any) error
}
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
// It is an incomplete implementation from "Use templates for issue e-mail subject and body" https://github.com/go-gitea/gitea/pull/8329
SubjectTemplates *texttmpl.Template
tmplRenderer *tmplRender
mockedBodyTemplates map[string]*template.Template
}
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
func mailSubjectTextFuncMap() texttmpl.FuncMap {
return texttmpl.FuncMap{
"dict": dict,
"Eval": evalTokens,
"EllipsisString": util.EllipsisDisplayString,
"AppName": func() string {
return setting.AppName
},
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
}
}
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
func newMailRenderer() (*MailRender, error) {
subjectTemplates := texttmpl.New("")
subjectTemplates.Funcs(mailSubjectTextFuncMap())
renderer := &MailRender{
SubjectTemplates: subjectTemplates,
}
assetFS := AssetFS()
renderer.tmplRenderer = &tmplRender{
collectTemplateNames: func() ([]string, error) {
names, err := assetFS.ListAllFiles(".", true)
if err != nil {
return nil, err
}
names = slices.DeleteFunc(names, func(file string) bool {
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
})
for i, name := range names {
names[i] = strings.TrimSuffix(strings.TrimPrefix(name, "mail/"), ".tmpl")
}
renderer.TemplateNames = names
return names, nil
},
readTemplateContent: func(name string) ([]byte, error) {
content, err := assetFS.ReadFile("mail/" + name + ".tmpl")
if err != nil {
return nil, err
}
var subjectContent []byte
bodyContent := content
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
subjectContent, bodyContent = content[0:loc[0]], content[loc[1]:]
}
_, err = renderer.SubjectTemplates.New(name).Parse(string(subjectContent))
if err != nil {
return nil, err
}
return bodyContent, nil
},
}
renderer.BodyTemplates.HasTemplate = func(name string) bool {
if renderer.mockedBodyTemplates[name] != nil {
return true
}
return renderer.tmplRenderer.Templates().HasTemplate(name)
}
staticFuncMap := NewFuncMap()
renderer.BodyTemplates.ExecuteTemplate = func(w io.Writer, name string, data any) error {
if t, ok := renderer.mockedBodyTemplates[name]; ok {
return t.Execute(w, data)
}
t, err := renderer.tmplRenderer.Templates().Executor(name, staticFuncMap)
if err != nil {
return err
}
return t.Execute(w, data)
}
err := renderer.tmplRenderer.recompileTemplates(staticFuncMap)
if err != nil {
return nil, err
}
return renderer, nil
}
func (r *MailRender) MockTemplate(name, subject, body string) func() {
if r.mockedBodyTemplates == nil {
r.mockedBodyTemplates = make(map[string]*template.Template)
}
oldSubject := r.SubjectTemplates
r.SubjectTemplates, _ = r.SubjectTemplates.Clone()
texttmpl.Must(r.SubjectTemplates.New(name).Parse(subject))
oldBody, hasOldBody := r.mockedBodyTemplates[name]
mockFuncMap := NewFuncMap()
r.mockedBodyTemplates[name] = template.Must(template.New(name).Funcs(mockFuncMap).Parse(body))
return func() {
r.SubjectTemplates = oldSubject
if hasOldBody {
r.mockedBodyTemplates[name] = oldBody
} else {
delete(r.mockedBodyTemplates, name)
}
}
}
var (
globalMailRenderer *MailRender
globalMailRendererMu sync.RWMutex
)
func MailRendererReload() error {
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
r, err := newMailRenderer()
if err != nil {
return err
}
globalMailRenderer = r
return nil
}
func MailRenderer() *MailRender {
globalMailRendererMu.RLock()
r := globalMailRenderer
globalMailRendererMu.RUnlock()
if r != nil {
return r
}
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
if globalMailRenderer != nil {
return globalMailRenderer
}
var err error
globalMailRenderer, err = newMailRenderer()
if err != nil {
log.Fatal("Failed to initialize mail renderer: %v", err)
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
r, err := newMailRenderer()
if err != nil {
log.Error("Mail template error: %v", err)
return
}
globalMailRenderer = r
})
}
return globalMailRenderer
}

View File

@@ -1,117 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"fmt"
"html/template"
"regexp"
"strings"
"sync/atomic"
texttmpl "text/template"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type MailTemplates struct {
TemplateNames []string
BodyTemplates *template.Template
SubjectTemplates *texttmpl.Template
}
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
func mailSubjectTextFuncMap() texttmpl.FuncMap {
return texttmpl.FuncMap{
"dict": dict,
"Eval": evalTokens,
"EllipsisString": util.EllipsisDisplayString,
"AppName": func() string {
return setting.AppName
},
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
}
}
func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error {
// Split template into subject and body
var subjectContent []byte
bodyContent := content
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
subjectContent = content[0:loc[0]]
bodyContent = content[loc[1]:]
}
if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil {
return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err)
}
if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil {
return fmt.Errorf("failed to parse template [%s/body]: %w", name, err)
}
return nil
}
// LoadMailTemplates provides the templates required for sending notification mails.
func LoadMailTemplates(ctx context.Context, loadedTemplates *atomic.Pointer[MailTemplates]) {
assetFS := AssetFS()
refreshTemplates := func(firstRun bool) {
var templateNames []string
subjectTemplates := texttmpl.New("")
bodyTemplates := template.New("")
subjectTemplates.Funcs(mailSubjectTextFuncMap())
bodyTemplates.Funcs(NewFuncMap())
if !firstRun {
log.Trace("Reloading mail templates")
}
assetPaths, err := ListMailTemplateAssetNames(assetFS)
if err != nil {
log.Error("Failed to list mail templates: %v", err)
return
}
for _, assetPath := range assetPaths {
content, layerName, err := assetFS.ReadLayeredFile(assetPath)
if err != nil {
log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err)
continue
}
tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
if firstRun {
log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
}
templateNames = append(templateNames, tmplName)
if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
if firstRun {
log.Fatal("Failed to parse mail template, err: %v", err)
}
log.Error("Failed to parse mail template, err: %v", err)
}
}
loaded := &MailTemplates{
TemplateNames: templateNames,
BodyTemplates: bodyTemplates,
SubjectTemplates: subjectTemplates,
}
loadedTemplates.Store(loaded)
}
refreshTemplates(true)
if !setting.IsProd {
// Now subjectTemplates and bodyTemplates are both synchronized
// thus it is safe to call refresh from a different goroutine
go assetFS.WatchLocalChanges(ctx, func() {
refreshTemplates(false)
})
}
}

98
modules/templates/page.go Normal file
View File

@@ -0,0 +1,98 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"html/template"
"io"
"net/http"
"slices"
"strings"
"sync"
texttemplate "text/template"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type pageRenderer struct {
tmplRenderer *tmplRender
}
func (r *pageRenderer) funcMap(ctx context.Context) template.FuncMap {
pageFuncMap := NewFuncMap()
pageFuncMap["ctx"] = func() any { return ctx }
return pageFuncMap
}
func (r *pageRenderer) funcMapDummy() template.FuncMap {
dummyFuncMap := NewFuncMap()
dummyFuncMap["ctx"] = func() any { return nil } // for template compilation only, no context available
return dummyFuncMap
}
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx))
}
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
name := string(tplName)
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 := r.TemplateLookup(name, templateCtx)
if err != nil {
return texttemplate.ExecError{Name: name, Err: err}
}
return t.Execute(w, data)
}
var PageRenderer = sync.OnceValue(func() *pageRenderer {
rendererType := util.Iif(setting.IsProd, "static", "auto-reloading")
log.Debug("Creating %s HTML Renderer", rendererType)
assetFS := AssetFS()
tr := &tmplRender{
collectTemplateNames: func() ([]string, error) {
names, err := assetFS.ListAllFiles(".", true)
if err != nil {
return nil, err
}
names = slices.DeleteFunc(names, func(file string) bool {
return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
})
for i, file := range names {
names[i] = strings.TrimSuffix(file, ".tmpl")
}
return names, nil
},
readTemplateContent: func(name string) ([]byte, error) {
return assetFS.ReadFile(name + ".tmpl")
},
}
pr := &pageRenderer{tmplRenderer: tr}
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
processStartupTemplateError(err)
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
log.Error("Template error: %v\n%s", err, log.Stack(2))
}
})
}
return pr
})
func PageRendererReload() error {
return PageRenderer().tmplRenderer.recompileTemplates(PageRenderer().funcMapDummy())
}

View File

@@ -61,6 +61,10 @@ func (t *ScopedTemplate) Freeze() {
t.all.Funcs(m)
}
func (t *ScopedTemplate) HasTemplate(name string) bool {
return t.all.Lookup(name) != nil
}
func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
t.scopedMu.RLock()
scopedTmplSet, ok := t.scopedTemplateSets[name]

View File

@@ -10,25 +10,6 @@ import (
"unicode/utf8"
)
// ErrWrongSyntax represents a wrong syntax with a template
type ErrWrongSyntax struct {
Template string
}
func (err ErrWrongSyntax) Error() string {
return "wrong syntax found in " + err.Template
}
// ErrVarMissing represents an error that no matched variable
type ErrVarMissing struct {
Template string
Var string
}
func (err ErrVarMissing) Error() string {
return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template)
}
// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors
// if error occurs, the error part doesn't change and is returned as it is.
func Expand(template string, vars map[string]string) (string, error) {
@@ -66,14 +47,14 @@ func Expand(template string, vars map[string]string) (string, error) {
posBegin = posEnd
if part == "{}" || part[len(part)-1] != '}' {
// treat "{}" or "{..." as error
err = ErrWrongSyntax{Template: template}
err = fmt.Errorf("wrong syntax found in %s", template)
buf.WriteString(part)
} else {
// now we get a valid key "{...}"
key := part[1 : len(part)-1]
keyFirst, _ := utf8.DecodeRuneInString(key)
if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) {
// the if key doesn't start with a letter, then we do not treat it as a var now
// if the key doesn't start with a letter, then we do not treat it as a var now
buf.WriteString(part)
} else {
// look up in the map
@@ -82,7 +63,7 @@ func Expand(template string, vars map[string]string) (string, error) {
} else {
// write the non-existing var as it is
buf.WriteString(part)
err = ErrVarMissing{Template: template, Var: key}
err = fmt.Errorf("the variable %s is missing for %s", key, template)
}
}
}