Skip to content

Instantly share code, notes, and snippets.

@kberov
Last active December 16, 2023 02:05
Show Gist options
  • Save kberov/f936d6d320d2517de069b4012815a0ed to your computer and use it in GitHub Desktop.
Save kberov/f936d6d320d2517de069b4012815a0ed to your computer and use it in GitHub Desktop.
A template and data manager for valya/fasttemplate with added wrapper and 'include' directives
package tmpls
import (
"bytes"
"fmt"
"io"
"log/slog"
"os"
"strings"
ft "github.com/valyala/fasttemplate"
)
func ExampleNew() {
tpls, _ := New("../tpls", ".htm", [2]string{"${", "}"}, false)
// If you need deeper recursive inclusion limit
tpls.IncludeLimit = 5
//...
}
func ExampleTmpls_Execute() {
var data = DataMap{
"title": "Здрасти",
"body": "<p>Едно тяло тук</p>",
"lang": "bg",
"generator": "Образци",
"included": "вложена",
}
tpls, _ := New("../tpls", ".htm", [2]string{"${", "}"}, false)
tpls.DataMap = data
var out strings.Builder
// Compile and xecute file ../tpls/view.htm
if length, err := tpls.Execute(&out, "view"); err != nil {
fmt.Printf("Length:%d\nOutput:\n%s", length, out.String())
} else {
os.Stderr.Write([]byte("err:" + err.Error()))
}
}
func ExampleTmpls_LoadFile() {
tpls, _ := New("../tpls", ".htm", [2]string{"${", "}"}, false)
// Replace some placeholder with static content
content, err := tpls.LoadFile("partials/_script")
if err != nil {
slog.Error(fmt.Sprintf("Problem loading partial template `script` %s", err.Error()))
}
tpls.DataMap["script"] = "<script>" + content + "</script>"
//...
// Prepare a function for replacing a placeholder
tpls.DataMap["other_books"] = ft.TagFunc(func(w io.Writer, tag string) (int, error) {
// for more complex file, containing wrapper and include directives, you
// must use tpls.Compile("path/to/file")
template, err := tpls.LoadFile("partials/_book_item")
if err != nil {
return 0, fmt.Errorf(
"Problem loading partial template `_book_item` in 'other_books' TagFunc: %s", err.Error())
}
rendered := bytes.NewBuffer([]byte(""))
booksFromDataBase := []map[string]any{
{"book_title": "Лечителката и рунтавата ѝ… котка", "book_author": "Контадин Кременски"},
{"book_title": "На пост", "book_author": "Николай Фенерски"},
}
for _, book := range booksFromDataBase {
if _, err := tpls.FtExecStd(template, rendered, book); err != nil {
return 0, fmt.Errorf(
"Problem rendering partial template `_book_item` in 'other_books' TagFunc: %s",
err.Error())
}
}
return w.Write(rendered.Bytes())
})
}
// Package tmpls provides a templates and data manager for
// [pkg/github.com/valyala/fasttemplate].
//
// Because [pkg/github.com/valyala/fasttemplate] is really minimalisitic, the need
// for this wrapper arose. Two template directives were implemented – `wrapper`
// and `include`. These make this simple templates' manager powerful enough for
// big and complex sites or generating any text output.
//
// The main template can be compiled from several files – as many as you need –
// with the simple technic of wrapping and including files recursively.
// fasttemplate's TagFunc allows us to keep logic into our go code and compose
// pieces of the output as needed. See the tests for usage examples.
package tmpls
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
ft "github.com/valyala/fasttemplate"
)
var spf = fmt.Sprintf
// path => slurped file content
type filesMap map[string]string
// A map for replacement into templates. Can have the same value types, needed
// for fasttemplate:
// - []byte - the fastest value type
// - string - convenient value type
// - TagFunc - flexible value type
type DataMap map[string]any
// Manager for files and data for fasttemplate.
type Tmpls struct {
// A map for replacement into templates
DataMap DataMap
// file name => file contents
files filesMap
// compiled templates
compiled filesMap
// File extension of the templates, for example: ".htm".
Ext string
// Root folder, where template files reside, fo example "./templates"
root string
// Pair of Tags, for example: "${", "}".
Tags [2]string
// How deep files can be included into one each oder.
// Default: 3 starting from 0 in the main template.
IncludeLimit int
// To wait for storeCompiled() to finish.
wg sync.WaitGroup
}
// Instantiates a new [Tmpls] struct and returns it. Prepares [DataMap] and
// loads all template files from disk under the given `root` if `loadFiles` is
// true. Otherwise postpones the loading of the needed file until
// [Tmpls.Compile] is invoked automatically in [Tmpls.Execute].
func New(root string, ext string, tags [2]string, loadFiles bool) (*Tmpls, error) {
root = findRoot(root)
t := &Tmpls{DataMap: make(DataMap, 5), compiled: make(filesMap, 5),
files: make(filesMap, 5), Ext: ext, root: root, Tags: tags, IncludeLimit: 3}
if loadFiles {
t.loadFiles()
}
return t, nil
}
// Compiles a template. This means that:
// - The file is loaded from disk using [Tmpls.LoadFile] and stored in a
// private map[filename(string)]string.
// - if the template contains `${wrapper some/file}`, the wrapper file is
// wrapped around it.
// - if the template contains any `${include some/file}` the files are
// loaded, wrapped (if there is a wrapper directive in them) and included
// at these places without rendering any other placeholders. The inclusion
// is done recursively. See *Tmpls.IncludeLimit.
// - The compiled template is stored in a private map[filename(string)]string,
// attached to *Tmpls.
//
// This is done only if the file was not already compiled once. Returns the
// compiled text or an error. Panics in case the *Tmpls.IncludeLimit is reached. If
// you have deeply nested included files you may need to set a bigger integer.
// This method is suitable for use in a ft.TagFunc to compile parts to be
// replaced in bigger templates.
func (t *Tmpls) Compile(path string) (string, error) {
path = t.toFullPath(path)
if text, e := t.loadCompiled(path); e == nil {
return text, nil
}
text, err := t.LoadFile(path)
if err != nil {
return "", err
}
if text, err = t.wrap(text); err != nil {
return text, err
}
if text, err = t.include(text); err != nil {
return text, err
}
t.compiled[path] = text
t.wg.Add(1)
go t.storeCompiled(path, t.compiled[path])
return t.compiled[path], nil
}
func (t *Tmpls) loadCompiled(fullPath string) (string, error) {
if text, ok := t.compiled[fullPath]; ok {
return text, nil
}
println("in loadCompiled...")
fullPath = fullPath + "c"
if fileIsReadable(fullPath) {
if data, err := os.ReadFile(fullPath); err != nil {
return "", err
} else {
t.compiled[fullPath] = string(data)
return t.compiled[fullPath], nil
}
}
return "", errors.New(spf("File '%s' could not be read!", fullPath))
}
func (t *Tmpls) storeCompiled(fullPath, text string) {
defer t.wg.Done()
println("in storeCompiled...")
err := os.WriteFile(fullPath+"c", []byte(text), 0600)
if err != nil {
panic(err)
}
}
// Loads and compiles, if needed, and executes the passed template using
// [fasttemplate.Execute].
func (t *Tmpls) Execute(w io.Writer, path string) (int64, error) {
text, err := t.Compile(path)
if err != nil {
return 0, err
}
length, err := ft.Execute(text, t.Tags[0], t.Tags[1], w, t.DataMap)
t.wg.Wait()
return length, err
}
// A wrapper for fasttemplate.ExecuteStd(). Useful for preparing partial
// templates which will be later included in the main template, because it
// keeps unknown placeholders untouched.
func (t *Tmpls) FtExecStd(tmpl string, w io.Writer, data map[string]any) (int64, error) {
return ft.ExecuteStd(tmpl, t.Tags[0], t.Tags[1], w, data)
}
func (t *Tmpls) loadFiles() {
filepath.WalkDir(t.root, func(path string, d fs.DirEntry, err error) error {
if strings.HasSuffix(path, t.Ext) {
if _, err = t.LoadFile(path); err != nil {
log.Fatalf("Error loading template '%s'", path)
}
}
return err
})
}
// Loads a template from disk or from cache, if already loaded before.
// Returns the template text or error if template cannot be loaded.
func (t *Tmpls) LoadFile(path string) (string, error) {
path = t.toFullPath(path)
if text, ok := t.files[path]; ok && len(text) > 0 {
return text, nil
}
if fileIsReadable(path) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
t.files[path] = string(data)
return t.files[path], nil
}
return "", errors.New(spf("File '%s' could not be read!", path))
}
func (t *Tmpls) toFullPath(path string) string {
if !strings.HasSuffix(path, t.Ext) {
path = path + t.Ext
}
if !strings.HasPrefix(path, t.root) {
path = filepath.Join(t.root, path)
}
return path
}
// Merges additional entries into the data map, used by [fasttemplate Execute]
// in [Tmpls.Execute]. If entries with the same key exist, they will be
// overriden with the new values.
func (t *Tmpls) MergeDataMap(data DataMap) {
for k, v := range data {
t.DataMap[k] = v
}
}
// Tries to return an existing absolute path to the given root path. If the
// provided root is relative, the function expects the root to be relative to
// the Executable file or to the current working directory. If the root does
// not exist, this function panics.
func findRoot(root string) string {
if !filepath.IsAbs(root) {
byExe := filepath.Join(findBinDir(), root)
if dirExists(byExe) {
return byExe
}
// Now try by CWD
byCwd, _ := filepath.Abs(root)
if dirExists(byCwd) {
return byCwd
} else { // this is dead code but Go compiler made me write it
panic(spf("Templates root directory '%s' does not exist!", byCwd))
}
}
if dirExists(root) {
return root
} else { // this is dead code but Go compiler made me write it
panic(spf("Templates root directory '%s' does not exist!", root))
}
}
func dirExists(path string) bool {
finfo, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
return false
}
if finfo.IsDir() {
return true
}
return false
}
func fileIsReadable(path string) bool {
finfo, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
return false
}
if finfo.Mode().IsRegular() && finfo.Mode().Perm()&0400 == 0400 {
return true
}
return false
}
func findBinDir() string {
exe, err := os.Executable()
if err != nil {
panic(err)
}
return filepath.Dir(exe)
}
// Replaces all occurances of `include path/to/template` in `text` with the
// contents of the partial templates. Panics in case the t.IncludeLimit is
// reached. If you have deeply nested included files you may need to set a
// bigger integer.
func (t *Tmpls) include(text string) (string, error) {
restr := spf(`(?m)\Q%s\E(include\s+([/\w]+))\Q%s\E`, t.Tags[0], t.Tags[1])
reInclude := regexp.MustCompile(restr)
matches := reInclude.FindAllStringSubmatch(text, -1)
included := bytes.NewBuffer([]byte(""))
howMany := len(matches)
if howMany > 0 {
data := make(map[string]any, howMany)
for _, m := range matches {
if t.detectInludeRecurionLimit() {
panic(spf("Limit of %d nested inclusions reached"+
" while trying to include %s", t.IncludeLimit, m[2]))
//return text, nil
}
includedFileContent, err := t.LoadFile(m[2])
if err != nil {
log.Printf("err:%s", err.Error())
return text, err
}
includedFileContent, err = t.wrap(strings.Trim(includedFileContent, "\n"))
if err != nil {
return text, err
}
data[m[1]], err = t.include(includedFileContent)
if err != nil {
return text, err
}
}
// Keep unknown placeholders for the main Execute!
if _, err := t.FtExecStd(text, included, data); err != nil {
return text, err
}
return included.String(), nil
}
return text, nil
}
// If a template file contains `${wrap some/file}`, then `some/file` is
// loaded and the content is put in it in place of `${content}`. This
// means that `content` tag is special in wrapper templates and cannot be used
// as a regular placeholder. Only one `wrapper` directive is allowed per file.
// Returns the wrapped template text or the passed text with error.
func (t *Tmpls) wrap(text string) (string, error) {
re := spf(`(?m)\n?\Q%s\E(wrapper\s+([/\w]+))\Q%s\E\n?`, t.Tags[0], t.Tags[1])
reWrapper := regexp.MustCompile(re)
// allow only one wrapper
match := reWrapper.FindAllStringSubmatch(text, 1)
if len(match) > 0 && len(match[0]) == 3 {
wrapper, err := t.LoadFile(string(match[0][2]))
if err != nil {
return text, err
}
text = reWrapper.ReplaceAllString(strings.Trim(text, "\n"), "")
text = strings.Replace(wrapper, spf("%scontent%s", t.Tags[0], t.Tags[1]), text, 1)
}
return text, nil
}
// frames = 1 : direct recursion - calls it self - fine.
// frames < t.IncludeLimit : direct recursion - calls it self - still fine.
// frames == t.IncludeLimit : indirect - some caller on t.IncludeLimit call
// frame still calls the same function - too many recursion levels - stop.
func (t *Tmpls) detectInludeRecurionLimit() bool {
pcme, _, _, _ := runtime.Caller(1)
detailsme := runtime.FuncForPC(pcme)
pc, _, _, _ := runtime.Caller(1 + t.IncludeLimit)
details := runtime.FuncForPC(pc)
return (details != nil) && detailsme.Name() == details.Name()
}
//
// [fasttemplate Execute]: https://github.com/valyala/fasttemplate
package tmpls
import (
"bytes"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
ft "github.com/valyala/fasttemplate"
)
var templatesroot = "../tpls"
var ext = ".htm"
// remove all compiled previously templates
func init() {
sfx := ext + "c"
filepath.WalkDir(templatesroot, func(path string, d fs.DirEntry, err error) error {
if strings.HasSuffix(path, sfx) {
os.Remove(path)
}
return err
})
}
func TestNew(t *testing.T) {
// load templates
tpls, err := New(templatesroot, ext, [2]string{"${", "}"}, true)
if err != nil {
t.Fatal("Error New: ", err.Error())
}
for k := range tpls.files {
t.Logf("file: %s", k)
}
// do not load templates
tpls, err = New(templatesroot, ext, [2]string{"${", "}"}, false)
if err != nil {
t.Fatal("Eror New: ", err.Error())
}
if len(tpls.files) > 0 {
t.Fatal("templates should not be loaded")
}
}
var data = DataMap{
"title": "Здрасти",
"body": "<p>Едно тяло тук</p>",
"lang": "bg",
"generator": "Образци",
"included": "вложена",
}
func TestExecute(t *testing.T) {
tpls, _ := New(templatesroot, ext, [2]string{"${", "}"}, false)
tpls.DataMap = data
var out strings.Builder
_, _ = tpls.Execute(&out, "view")
outstr := out.String()
t.Log(outstr)
for k, v := range data {
if !strings.Contains(outstr, v.(string)) {
t.Fatalf("output does not contain expected value for '%s': %s", k, v)
}
}
//Change keys and check if they ar changed in the output
// Same view with other data
t.Log("=================")
tpls.DataMap = DataMap{
"title": "Hello",
"body": "<p>A body here</p>",
"lang": "en",
"generator": "Tmpls",
"included": "included",
}
out.Reset()
_, _ = tpls.Execute(&out, "view")
outstr = out.String()
t.Log(outstr)
for k, v := range tpls.DataMap {
if !strings.Contains(outstr, v.(string)) {
t.Fatalf("output does not contain expected value for '%s': %s", k, v)
}
}
}
func TestAddExecuteFunc(t *testing.T) {
tpls, _ := New(templatesroot, ext, [2]string{"${", "}"}, false)
tpls.DataMap = DataMap{
"a": "a value",
"b": "b value",
}
// ...
// Later in a galaxy far away
// ....
// Prepare a book for display and prepare a list of other books
tpls.MergeDataMap(map[string]any{
"lang": "en",
"generator": "Tmpls",
"included": "вложена",
"book_title": "Историософия", "book_author": "Николай Гочев",
"book_isbn": "9786199169056", "book_issuer": "Студио Беров",
})
// Prepare a function for rendering other books
tpls.DataMap["other_books"] = ft.TagFunc(func(w io.Writer, tag string) (int, error) {
// for more complex file, containing wrapper and include directives, you
// must use tpls.Compile("path/to/file")
template, err := tpls.LoadFile("partials/_book_item")
if err != nil {
return 0, fmt.Errorf(
"Problem loading partial template `_book_item` in 'other_books' TagFunc: %s", err.Error())
}
rendered := bytes.NewBuffer([]byte(""))
booksFromDataBase := []map[string]any{
{"book_title": "Лечителката и рунтавата ѝ… котка", "book_author": "Контадин Кременски"},
{"book_title": "На пост", "book_author": "Николай Фенерски"},
}
for _, book := range booksFromDataBase {
if _, err := ft.Execute(template, tpls.Tags[0], tpls.Tags[1], rendered, book); err != nil {
return 0, fmt.Errorf("Problem rendering partial template `_book_item` in 'other_books' TagFunc: %s", err.Error())
}
}
return w.Write(rendered.Bytes())
})
// Even later, when the whole page is put together
var out strings.Builder
_, err := tpls.Execute(&out, "book")
if err != nil {
log.Fatalf("Error executing Tmpls.Execute: %s", err.Error())
}
outstr := out.String()
t.Log(outstr)
}
func TestIncludeLimitPanic(t *testing.T) {
tpls, _ := New(templatesroot, ext, [2]string{"${", "}"}, false)
tpls.DataMap = DataMap{
"title": "Possibly recursive inclusions",
"generator": "Tmpls",
"included": "included",
}
level := 0
tpls.DataMap["level"] = ft.TagFunc(func(w io.Writer, tag string) (int, error) {
level++
return w.Write([]byte(spf("%d", level)))
})
var out strings.Builder
expectPanic(t, func() { _, _ = tpls.Execute(&out, "includes") })
}
func TestOtherPanics(t *testing.T) {
tpls, _ := New(templatesroot, ext, [2]string{"${", "}"}, false)
path := "/ff/a.htm"
tpls.compiled[path] = "bla"
tpls.wg.Add(1)
expectPanic(t, func() { tpls.storeCompiled(path, tpls.compiled[path]) })
// abs. path
expectPanic(t, func() { findRoot(path) })
// rel. path
expectPanic(t, func() { findRoot("." + path) })
}
func TestIncludeLimitNoPanic(t *testing.T) {
tpls, _ := New(templatesroot, ext, [2]string{"${", "}"}, false)
tpls.DataMap = DataMap{
"title": "Possibly recursive inclusions",
"generator": "Tmpls",
"included": "included",
}
level := 0
tpls.DataMap["level"] = ft.TagFunc(func(w io.Writer, tag string) (int, error) {
level++
return w.Write([]byte(spf("%d", level)))
})
tpls.IncludeLimit = 7
level = 0
var out strings.Builder
_, err := tpls.Execute(&out, "includes")
if err != nil {
log.Fatalf("Error executing Tmpls.Execute: %s", err.Error())
}
outstr := out.String()
t.Log(outstr)
if !strings.Contains(outstr, "4 4") {
t.Fatalf("output does not contain expected value 4 4")
}
}
func expectPanic(t *testing.T, f func()) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("missing panic")
} else {
t.Log(r)
}
}()
f()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment