-
-
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() | |
| } |
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 lineThis 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

@frederikhors , I've updated the
load.go. And I've not tested it.Use the templates