Last active
December 16, 2023 02:05
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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