go: refactor template rendering
All checks were successful
continuous-integration/drone/push Build is passing

* create pkg 'modules/template'
* move template rendering code from 'handlers' to 'modules/template'
* update call sites
* walk the 'templates' dir to discover nested hierarchies
* solidify LiveMode handling (vs embedded assets)
* break out funcMap to it's own file
* general clean-up
This commit is contained in:
leo 2023-05-11 04:32:39 +02:00
parent 61760fa373
commit 122ea638c9
Signed by: wanderer
SSH Key Fingerprint: SHA256:Dp8+iwKHSlrMEHzE3bJnPng70I7LEsa3IJXRH/U+idQ
9 changed files with 271 additions and 141 deletions

@ -59,7 +59,7 @@ func (a *App) getTemplates() fs.FS {
a.logger.Info("templates loaded in embed mode") a.logger.Info("templates loaded in embed mode")
fsys, err := fs.Sub(a.embeds.templates, "templates") fsys, err := fs.Sub(a.embeds.templates, ".")
if err != nil { if err != nil {
panic(err) panic(err)
} }

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"git.dotya.ml/mirre-mt/pcmt/handlers" "git.dotya.ml/mirre-mt/pcmt/handlers"
modtmpl "git.dotya.ml/mirre-mt/pcmt/modules/template"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
) )
@ -14,9 +15,11 @@ func (a *App) SetupRoutes() {
assets := http.FileServer(a.getAssets()) assets := http.FileServer(a.getAssets())
tmpls := a.getTemplates() tmpls := a.getTemplates()
modtmpl.Init(setting, tmpls)
// run this before declaring any handler funcs. // run this before declaring any handler funcs.
handlers.InitHandlers(setting, tmpls) handlers.InitHandlers(setting)
e.Renderer = handlers.Renderer
e.Renderer = modtmpl.Renderer
// keep /static/* as a compatibility fallback for /assets. // keep /static/* as a compatibility fallback for /assets.
e.GET( e.GET(

@ -1,22 +1,19 @@
package handlers package handlers
import ( import (
"io/fs"
"git.dotya.ml/mirre-mt/pcmt/app/settings" "git.dotya.ml/mirre-mt/pcmt/app/settings"
"git.dotya.ml/mirre-mt/pcmt/slogging" "git.dotya.ml/mirre-mt/pcmt/slogging"
"golang.org/x/exp/slog" "golang.org/x/exp/slog"
) )
var ( var (
setting *settings.Settings setting *settings.Settings
appver string appver string
slogger *slogging.Logger slogger *slogging.Logger
log slogging.Logger log slogging.Logger
tmplPath string
) )
func InitHandlers(s *settings.Settings, tmpls fs.FS) { func InitHandlers(s *settings.Settings) {
slogger = slogging.GetLogger() slogger = slogging.GetLogger()
log = *slogger // have a local copy. log = *slogger // have a local copy.
log.Logger = log.Logger.With( log.Logger = log.Logger.With(
@ -25,11 +22,5 @@ func InitHandlers(s *settings.Settings, tmpls fs.FS) {
setting = s setting = s
tmplPath = setting.TemplatesPath()
appver = setting.Version() appver = setting.Version()
log.Debugf("tmpls: %+v", tmpls)
initTemplates(tmpls)
} }

@ -1,113 +1,13 @@
package handlers package handlers
import ( import (
"html/template"
"io/fs"
"net/http" "net/http"
"path/filepath"
"strings"
modtmpl "git.dotya.ml/mirre-mt/pcmt/modules/template"
"github.com/labstack/echo-contrib/session" "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
) )
type tplMap map[string]*template.Template
var (
// tmpl *template.Template.
// tmpls = template.New("").
templateMap tplMap
tmplFS fs.FS
bluemondayPolicy = bluemonday.UGCPolicy()
)
func listAllTmpls() []string {
files, err := filepath.Glob(tmplPath + "/*.tmpl")
if err != nil {
panic(err)
}
for i, v := range files {
files[i] = strings.TrimPrefix(v, tmplPath+"/")
}
log.Debug("returning files")
return files
}
// TODO: mimic https://github.com/drone/funcmap/blob/master/funcmap.go
func setFuncMap(t *template.Template) {
t.Funcs(getFuncMap())
}
func getFuncMap() template.FuncMap {
return template.FuncMap{
"ifIE": func() template.HTML { return template.HTML("<!--[if IE]>") },
"endifIE": func() template.HTML { return template.HTML("<![endif]>") },
"htmlSafe": func(html string) template.HTML {
return template.HTML( //nolint:gosec
bluemondayPolicy.Sanitize(html),
)
},
"pageIs": func(want, got string) bool {
return want == got
},
}
}
func initTemplates(f fs.FS) {
if f != nil || f != tmplFS {
tmplFS = f
}
setFuncMap(Renderer.tmpls)
allTmpls := listAllTmpls()
// ensure this fails at compile time, if at all ("Must").
Renderer.tmpls = template.Must(Renderer.tmpls.ParseFS(tmplFS, allTmpls...))
makeTplMap(Renderer.tmpls)
}
func makeTplMap(tpl *template.Template) {
allTmpls := listAllTmpls()
var tp tplMap
if templateMap == nil {
tp = make(tplMap, len(allTmpls))
} else {
tp = templateMap
}
for _, v := range allTmpls {
tp[v] = tpl.Lookup(v)
}
templateMap = tp
}
func getTmpl(name string) *template.Template {
liveMode := setting.IsLive()
if liveMode {
log.Debug("re-reading tmpls") // TODO: only re-read the tmpl in question.
tpl := template.New("")
setFuncMap(tpl)
allTmpls := listAllTmpls()
// ensure this fails at compile time, if at all ("Must").
Renderer.tmpls = template.Must(tpl.ParseFS(tmplFS, allTmpls...))
}
return Renderer.tmpls.Lookup(name)
}
func Admin() echo.HandlerFunc { func Admin() echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials")
@ -123,7 +23,7 @@ func Index() echo.HandlerFunc {
return c.Redirect(http.StatusFound, "/home") return c.Redirect(http.StatusFound, "/home")
} }
tpl := getTmpl("index.tmpl") tpl := modtmpl.Get("index.tmpl")
csrf := c.Get("csrf").(string) csrf := c.Get("csrf").(string)
err := tpl.Execute(c.Response().Writer, err := tpl.Execute(c.Response().Writer,

@ -1,22 +0,0 @@
package handlers
import (
"html/template"
"io"
"github.com/labstack/echo/v4"
)
type TemplateRenderer struct {
tmpls *template.Template
}
var Renderer = &TemplateRenderer{tmpls: template.New("")}
func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
c.Logger().Debugf("rendering template %s", name)
tpl := getTmpl(name)
return tpl.Execute(w, data)
}

@ -0,0 +1,23 @@
package template
import "html/template"
func FuncMap() template.FuncMap {
return template.FuncMap{
"ifIE": func() template.HTML { return template.HTML("<!--[if IE]>") },
"endifIE": func() template.HTML { return template.HTML("<![endif]>") },
"htmlSafe": func(html string) template.HTML {
return template.HTML( //nolint:gosec
bluemondayPolicy.Sanitize(html),
)
},
"pageIs": func(want, got string) bool {
return want == got
},
}
}
// TODO: mimic https://github.com/drone/funcmap/blob/master/funcmap.go
func setFuncMap(t *template.Template) { //nolint:unused
t.Funcs(FuncMap())
}

@ -0,0 +1,71 @@
package template
import (
"io/fs"
"path/filepath"
)
// globDir lists files inside a dir that match the suffix provided.
func globDir(dir, suffix string) ([]string, error) {
files := []string{}
err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error {
if de.IsDir() {
return nil
}
if filepath.Ext(path) == suffix {
files = append(files, path)
}
return nil
})
return files, err
}
// globFS lists files inside a dir of a fs that match the suffix provided.
func globFS(fspls fs.FS, dir, suffix string) ([]string, error) {
files := []string{}
err := fs.WalkDir(fspls, dir, func(path string, de fs.DirEntry, err error) error {
if de.IsDir() {
return nil
}
if filepath.Ext(path) == suffix {
files = append(files, path)
}
return nil
})
return files, err
}
// listAll returns template file names as []string and panics on error.
// uses filepath.WalkDir via glob* funcs. TODO: don't panic.
func listAll(pattern string) []string {
files := make([]string, 0)
switch {
case !setting.IsLive():
f, err := globFS(tmplFS, ".", pattern)
if err != nil {
log.Errorf("error: %q", err)
panic(err)
}
files = f
case setting.IsLive():
f, err := globDir(tmplPath, pattern)
if err != nil {
panic(err)
}
files = f
}
return files
}

@ -0,0 +1,22 @@
package template
import (
"html/template"
"io"
"github.com/labstack/echo/v4"
)
type TplRenderer struct {
tmpls *template.Template
}
var Renderer = &TplRenderer{tmpls: template.New("")}
func (t *TplRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
c.Logger().Debugf("rendering template %s", name)
tpl := Get(name)
return tpl.Execute(w, data)
}

@ -0,0 +1,142 @@
package template
import (
"html/template"
"io/fs"
"io/ioutil"
"strings"
"git.dotya.ml/mirre-mt/pcmt/app/settings"
"git.dotya.ml/mirre-mt/pcmt/slogging"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/exp/slog"
)
type tplMap map[string]*template.Template
var (
templateMap tplMap
// embedded templates.
tmplFS fs.FS
bluemondayPolicy = bluemonday.UGCPolicy()
setting *settings.Settings
// global logger.
slogger *slogging.Logger
// local logger copy.
log slogging.Logger
// path to "templates" folder (empty if LiveMode).
tmplPath string
tmplSuffix = ".tmpl"
)
// Init saves settings and embedded templates to package-level state and also
// "initialises" template rendering.
func Init(s *settings.Settings, tmpls fs.FS) {
slogger = slogging.GetLogger()
log = *slogger // have a local copy.
log.Logger = log.Logger.With(
slog.Group("pcmt extra", slog.String("module", "modules/template")),
)
setting = s
tmplPath = setting.TemplatesPath() // will be empty if LiveMode.
initTemplates(tmpls)
log.Debug(Renderer.tmpls.DefinedTemplates())
}
// Get returns a template requested either from the tmplPath (in LiveMode) or
// from the pre-compiled tmpl list set directly to Renderer.
func Get(name string) *template.Template {
liveMode := setting.IsLive()
if liveMode {
log.Debug("re-reading tmpls") // TODO: only re-read the tmpl in question.
allTmpls := listAll(tmplSuffix)
// create fresh tmpl root.
t := template.New("")
for i, v := range allTmpls {
rawfile, err := ioutil.ReadFile(v)
if err != nil {
panic(err)
}
allTmpls[i] = strings.TrimPrefix(v, tmplPath+"/")
t = t.New(allTmpls[i]).Funcs(FuncMap())
template.Must(t.Parse(string(rawfile)))
}
Renderer.tmpls = t
}
return Renderer.tmpls.Lookup(name)
}
func initTemplates(f fs.FS) {
if !setting.IsLive() {
if f != nil || f != tmplFS {
tmplFS = f
}
}
allTmpls := listAll(tmplSuffix)
t := Renderer.tmpls
// ensure this fails at compile time, if at all ("Must").
if !setting.IsLive() {
for i, v := range allTmpls {
rawfile, err := fs.ReadFile(tmplFS, v)
if err != nil {
panic(err)
}
allTmpls[i] = strings.TrimPrefix(v, "templates/")
t := Renderer.tmpls.New(allTmpls[i]).Funcs(FuncMap())
template.Must(t.Parse(string(rawfile)))
}
Renderer.tmpls = t
} else {
for i, v := range allTmpls {
rawfile, err := ioutil.ReadFile(v)
if err != nil {
panic(err)
}
allTmpls[i] = strings.TrimPrefix(v, tmplPath+"/")
t = Renderer.tmpls.New(allTmpls[i]).Funcs(FuncMap())
template.Must(t.Parse(string(rawfile)))
}
Renderer.tmpls = t
}
makeTplMap(Renderer.tmpls, allTmpls)
}
func makeTplMap(tpl *template.Template, tplList []string) {
allTmpls := tplList
var tp tplMap
if templateMap == nil {
tp = make(tplMap, len(allTmpls))
} else {
tp = templateMap
}
for _, v := range allTmpls {
tp[v] = tpl.Lookup(v)
}
templateMap = tp
}