// Copyright 2021 Flamego. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package template import ( "bytes" "fmt" gotemplate "html/template" "log" "net/http" "os" "path/filepath" "sync" "time" "github.com/pkg/errors" "github.com/flamego/flamego" ) // Template is a Go template rendering engine. type Template interface { // HTML renders the named template with the given status. HTML(status int, name string) } var _ Template = (*template)(nil) type template struct { responseWriter flamego.ResponseWriter logger *log.Logger *gotemplate.Template Data contentType string bufPool *sync.Pool } func responseServerError(w http.ResponseWriter, err error) { if flamego.Env() == flamego.EnvTypeDev { http.Error(w, err.Error(), http.StatusInternalServerError) } else { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } func (t *template) HTML(status int, name string) { buf := t.bufPool.Get().(*bytes.Buffer) defer func() { buf.Reset() t.bufPool.Put(buf) }() started := time.Now() t.Data["RenderDuration"] = func() string { return fmt.Sprint(time.Since(started).Nanoseconds()/1e6) + "ms" } err := t.ExecuteTemplate(buf, name, t.Data) if err != nil { responseServerError(t.responseWriter, err) return } t.responseWriter.Header().Set("Content-Type", t.contentType+"; charset=utf-8") t.responseWriter.WriteHeader(status) _, err = buf.WriteTo(t.responseWriter) if err != nil { t.logger.Printf("template: failed to write out rendered HTML: %v", err) return } } // Data is used as the root object for rendering a template. type Data map[string]interface{} // Delims is a pair of Left and Right delimiters for rendering HTML templates. type Delims struct { // Left is the left delimiter. Default is "{{". Left string // Right is the right delimiter. Default is "}}". Right string } // Options contains options for the template.Templater middleware. type Options struct { // FileSystem is the interface for supporting any implementation of the // FileSystem. FileSystem FileSystem // Directory is the primary directory to load templates. This value is ignored // when FileSystem is set. Default is "templates". Directory string // AppendDirectories is a list of additional directories to load templates for // overwriting templates that are loaded from FileSystem or Directory. AppendDirectories []string // Extensions is a list of extensions to be used for template files. Default is // `[".tmpl", ".html"]`. Extensions []string // FuncMaps is a list of `template.FuncMap` to be applied for rendering // templates. FuncMaps []gotemplate.FuncMap // Delims is the pair of left and right delimiters for rendering templates. Delims Delims // ContentType specifies the value of "Content-Type". Default is "text/html". ContentType string } func newTemplate(allowedExtensions []string, funcMaps []gotemplate.FuncMap, delmis Delims, fs FileSystem, dir string, others ...string) (*gotemplate.Template, error) { if fs == nil { var err error fs, err = newFileSystem(dir, allowedExtensions) if err != nil { return nil, errors.Wrapf(err, "new file system") } } // Directories are composed in the reverse order because later ones overwrites // previous ones. Therefore, we can simply break of the loop once found an // overwritten when looping in the reverse order. dirs := make([]string, 0, len(others)) for i := len(others) - 1; i >= 0; i-- { dirs = append(dirs, others[i]) } for i := range dirs { if !isDir(dirs[i]) { continue } var err error dirs[i], err = filepath.EvalSymlinks(dirs[i]) if err != nil { return nil, errors.Wrapf(err, "eval symlinks for %q", dirs[i]) } } tpl := gotemplate.New("Flamego.Template").Delims(delmis.Left, delmis.Right) for _, f := range fs.Files() { t := tpl.New(f.Name()) for _, funcMap := range funcMaps { t.Funcs(funcMap) } var err error var data []byte // Loop over append directories and break out once found. for _, dir := range dirs { fpath := filepath.Join(dir, f.Name()+f.Ext()) if !isFile(fpath) { continue } data, err = os.ReadFile(fpath) if err != nil { return nil, errors.Wrap(err, "read") } break } if len(data) == 0 { data, err = f.Data() if err != nil { return nil, errors.Wrapf(err, "get data of %q", f.Name()) } } _, err = t.Parse(string(data)) if err != nil { return nil, errors.Wrapf(err, "parse %q", f.Name()) } } return tpl, nil } // Templater returns a middleware handler that injects template.Templater and // template.Data into the request context, which are used for rendering // templates to the ResponseWriter. // // When running with flamego.EnvTypeDev, if either Directory or // AppendDirectories is specified, templates will be recompiled upon every // request. func Templater(opts ...Options) flamego.Handler { var opt Options if len(opts) > 0 { opt = opts[0] } parseOptions := func(opts Options) Options { if opts.Directory == "" { opts.Directory = "templates" } if len(opts.Extensions) == 0 { opts.Extensions = []string{".tmpl", ".html"} } if opts.ContentType == "" { opts.ContentType = "text/html" } return opts } opt = parseOptions(opt) tpl, err := newTemplate(opt.Extensions, opt.FuncMaps, opt.Delims, opt.FileSystem, opt.Directory, opt.AppendDirectories...) if err != nil { panic("template: new template: " + err.Error()) } bufPool := &sync.Pool{ New: func() interface{} { return new(bytes.Buffer) }, } return flamego.LoggerInvoker(func(c flamego.Context, log *log.Logger) { t := &template{ responseWriter: c.ResponseWriter(), logger: log, Template: tpl, Data: make(Data), contentType: opt.ContentType, bufPool: bufPool, } if flamego.Env() == flamego.EnvTypeDev && (opt.Directory != "" || len(opt.AppendDirectories) > 0) { tpl, err := newTemplate(opt.Extensions, opt.FuncMaps, opt.Delims, opt.FileSystem, opt.Directory, opt.AppendDirectories...) if err != nil { http.Error( c.ResponseWriter(), fmt.Sprintf("template: %v", err), http.StatusInternalServerError, ) return } t.Template = tpl } c.MapTo(t, (*Template)(nil)) c.Map(t.Data) }) }