-
-
Save logrusorgru/abd846adb521a6fb39c7405f32fec0cf to your computer and use it in GitHub Desktop.
// | |
// Copyright (c) 2025 Konstantin Ivanov <[email protected]>. | |
// All rights reserved. This program is free software. It comes without | |
// any warranty, to the extent permitted by applicable law. You can | |
// redistribute it and/or modify it under the terms of the Unlicense. | |
// See LICENSE file for more details or see below. | |
// | |
// | |
// This is free and unencumbered software released into the public domain. | |
// | |
// Anyone is free to copy, modify, publish, use, compile, sell, or | |
// distribute this software, either in source code form or as a compiled | |
// binary, for any purpose, commercial or non-commercial, and by any | |
// means. | |
// | |
// In jurisdictions that recognize copyright laws, the author or authors | |
// of this software dedicate any and all copyright interest in the | |
// software to the public domain. We make this dedication for the benefit | |
// of the public at large and to the detriment of our heirs and | |
// successors. We intend this dedication to be an overt act of | |
// relinquishment in perpetuity of all present and future rights to this | |
// software under copyright law. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR | |
// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | |
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR | |
// OTHER DEALINGS IN THE SOFTWARE. | |
// | |
// For more information, please refer to <http://unlicense.org/> | |
// | |
package templates | |
import ( | |
"fmt" | |
"html/template" | |
"io/fs" | |
"os" | |
"path/filepath" | |
"strings" | |
) | |
type Option func(t *Tree) error | |
func FollowSymlinks() Option { | |
return func(t *Tree) (_ error) { | |
t.followSymlinks = true | |
return | |
} | |
} | |
func WithFuncs(funcsMap template.FuncMap) Option { | |
return func(t *Tree) (_ error) { | |
t.funcsMap = funcsMap | |
return | |
} | |
} | |
func WithExt(ext string) Option { | |
return func(t *Tree) (_ error) { | |
t.ext = ext | |
return | |
} | |
} | |
// A Tree implements keeper and loader for HTML templates based on directory | |
// tree. | |
type Tree struct { | |
*template.Template // root template | |
// configurations | |
ext string // only files with this extension | |
followSymlinks bool // | |
funcsMap template.FuncMap // | |
} | |
// New creates new Tree parsing given file system. | |
// For example: | |
// | |
// var t, err = templates.New(os.DirFS("./templates/"), | |
// templates.WithExt(".html"), | |
// templates.FollowSymlinks(), | |
// templates.WithFuncs(customFuncsMap)) | |
func New(dir fs.FS, opts ...Option) (t *Tree, err error) { | |
t = new(Tree) | |
t.Template = template.New("") // unnamed root template | |
for _, o := range opts { | |
if err = o(t); err != nil { | |
return | |
} | |
} | |
t.Funcs(t.funcsMap) | |
if err = t.load(dir, t.ext); err != nil { | |
return nil, err | |
} | |
return | |
} | |
// Load templates. The dir argument is a directory to load templates from. | |
// The ext argument is extension of tempaltes. | |
func (t *Tree) load(dir fs.FS, ext string) (err error) { | |
var walkFunc = func(path string, info fs.DirEntry, err error) (_ error) { | |
// handle walking error if any | |
if err != nil { | |
return fmt.Errorf("walking file system: %w", err) | |
} | |
// skip all except regular files | |
var realPath = path | |
if !info.Type().IsRegular() { | |
if info.Type()&os.ModeSymlink == 0 { | |
return // skip, not a regular file, nor a symbolic link | |
} | |
if t.followSymlinks { | |
return // skip, don't follow symbolic links | |
} | |
if realPath, err = filepath.EvalSymlinks(path); err != nil { | |
return fmt.Errorf("resolving symbolic link %q: %w", path, | |
err) | |
} | |
} | |
// filter by extension | |
if ext != "" { | |
if filepath.Ext(path) != ext { | |
return // skip | |
} | |
} | |
// name of a template is its relative path | |
// without extension | |
if ext != "" { | |
path = strings.TrimSuffix(path, ext) | |
} | |
path = strings.Join(strings.Split(path, string(os.PathSeparator)), "/") | |
// load or reload | |
var ( | |
nt = t.Template.New(path) | |
b []byte | |
) | |
if b, err = fs.ReadFile(dir, realPath); err != nil { | |
return err | |
} | |
_, err = nt.Parse(string(b)) | |
return err | |
} | |
if err = fs.WalkDir(dir, ".", walkFunc); err != nil { | |
return | |
} | |
return | |
} |
// | |
// Copyright (c) 2025 Konstantin Ivanov <[email protected]>. | |
// All rights reserved. This program is free software. It comes without | |
// any warranty, to the extent permitted by applicable law. You can | |
// redistribute it and/or modify it under the terms of the Unlicense. | |
// See LICENSE file for more details or see below. | |
// | |
// | |
// This is free and unencumbered software released into the public domain. | |
// | |
// Anyone is free to copy, modify, publish, use, compile, sell, or | |
// distribute this software, either in source code form or as a compiled | |
// binary, for any purpose, commercial or non-commercial, and by any | |
// means. | |
// | |
// In jurisdictions that recognize copyright laws, the author or authors | |
// of this software dedicate any and all copyright interest in the | |
// software to the public domain. We make this dedication for the benefit | |
// of the public at large and to the detriment of our heirs and | |
// successors. We intend this dedication to be an overt act of | |
// relinquishment in perpetuity of all present and future rights to this | |
// software under copyright law. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR | |
// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | |
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR | |
// OTHER DEALINGS IN THE SOFTWARE. | |
// | |
// For more information, please refer to <http://unlicense.org/> | |
// | |
package templates | |
import ( | |
"html/template" | |
"strings" | |
"testing" | |
"testing/fstest" | |
"time" | |
"github.com/stretchr/testify/assert" | |
"github.com/stretchr/testify/require" | |
) | |
// templates/ | |
// layout/ | |
// footer.html | |
// header.html | |
// main.html | |
// users/ | |
// entity.html | |
// list.html | |
// user.html | |
// | |
func TestTree(t *testing.T) { | |
var ( | |
mapfs = make(fstest.MapFS) | |
now = time.Now() | |
) | |
mapfs["templates/layout/header.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
HEADER | |
TITILE: {{ .Title }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
mapfs["templates/layout/footer.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
FOOTER | |
CONTACTS: {{ .Contacts }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
mapfs["templates/layout/main.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
{{ template "layout/header" . }} | |
MAIN CONTENT | |
{{ template "layout/footer" . }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
mapfs["templates/users/entity.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
- Name: {{ .Name }} | |
Age: {{ .Age }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
mapfs["templates/users/list.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
{{ template "layout/header" . }} | |
TOTAL {{ len .Users }} | |
{{ range .Users }} | |
{{ template "users/entity" . }} | |
{{ end }} | |
{{ template "layout/footer" . }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
mapfs["templates/users/user.html"] = &fstest.MapFile{ | |
Data: []byte(` | |
{{ template "layout/header" . }} | |
{{ with .User }} | |
{{ .Name }}, {{ .Age }} years old | |
{{ end }} | |
{{ fish }} | |
{{ template "layout/footer" . }} | |
`), | |
Mode: 0644, | |
ModTime: now, | |
} | |
type User struct { | |
Name string | |
Age int | |
} | |
type Data struct { | |
Title string | |
Contacts string | |
Users []User | |
User User | |
} | |
var data = Data{ | |
Title: "Testing Templates", | |
Contacts: "[email protected]", | |
Users: []User{ | |
{"Alex", 31}, | |
{"Alic", 25}, | |
{"Eva", 27}, | |
}, | |
User: User{"Abigail", 29}, | |
} | |
var sub, err = mapfs.Sub("templates") | |
require.NoError(t, err) | |
_, err = sub.Open("layout/footer.html") | |
require.NoError(t, err) | |
var customFuncsMap = template.FuncMap{ | |
"fish": func() string { | |
return "Lorem Ipsum Dolor Sit a met" | |
}, | |
} | |
var tr *Tree | |
tr, err = New(sub, | |
WithExt(".html"), | |
FollowSymlinks(), | |
WithFuncs(customFuncsMap)) | |
require.NoError(t, err) | |
var defTempl = tr.DefinedTemplates() | |
for _, dt := range []string{ | |
"layout/footer", | |
"layout/header", | |
"layout/main", | |
"users/entity", | |
"users/list", | |
"users/user", | |
} { | |
assert.True(t, strings.Contains(defTempl, dt)) | |
} | |
var out strings.Builder | |
err = tr.ExecuteTemplate(&out, "layout/main", data) | |
require.NoError(t, err) | |
assert.Equal(t, ` | |
HEADER | |
TITILE: Testing Templates | |
MAIN CONTENT | |
FOOTER | |
CONTACTS: [email protected] | |
`, out.String()) | |
out.Reset() | |
err = tr.ExecuteTemplate(&out, "users/list", data) | |
require.NoError(t, err) | |
assert.Equal(t, ` | |
HEADER | |
TITILE: Testing Templates | |
TOTAL 3 | |
- Name: Alex | |
Age: 31 | |
- Name: Alic | |
Age: 25 | |
- Name: Eva | |
Age: 27 | |
FOOTER | |
CONTACTS: [email protected] | |
`, out.String()) | |
out.Reset() | |
} |
@frederikhors , I've updated the load.go
. And I've not tested it.
var templs = NeTempl()
// may be not 100% correct here
templs.SetFuncs(template.FuncMap{
"title": strings.Title,
})
var err = templs.Load("./templates/", ".html")
if err != nil {
// handle error
}
Use the templates
// the w is a io.Writer, can be a http.ResponseWriter for example
err = templs.ExecuteTemplatte(w, "one/one-a", map[string]interface{
"key": "value, for example"
})
if err != nil {
// rendering or writing error
}
You forgot t.dir
on line https://gist.github.com/logrusorgru/abd846adb521a6fb39c7405f32fec0cf#file-load-go-L136.
Why did you remove the develop part? Do you find it no longer useful?
Plus I'm having a problem because I'm on Windows I think.
All the templates it finds have the key like: templates\customDir\subdir\file
.
And even if I use path.Join()
everywhere it won't find them unless I point to them using \
instead of /
(example "one\\one-a
" instead of "one/one-a
").
This is very strange. Do you understand why?
This is very strange. Do you understand why?
It uses relative filesystem path as a template name. You can add this line (below), to convert Windows-like paths to UNIX-like for a template name. The line is
rel = strings.TrimSuffix(rel, ext)
rel = strings.Join(strings.Split(rel, os.PathSeparator), "/") // additional line
This way, all template names will be UNIX-like (e.g. /
-separated). I've added this to the load.go
.
Why did you remove the develop part? Do you find it no longer useful?
Yes, I think it useless.
Maybe we should use: string(os.PathSeparator)
.
Yep
Hm. May be. Pass funcs in the NewTempl or move the Load out of the NewTempl and load outside, when functions are set. Also, I would change
Funcs
toSetFuncs
for a better naming. And I would get rid out of the development stuff (the.devel
and all related).