Skip to content

Instantly share code, notes, and snippets.

@logrusorgru
Last active March 15, 2025 06:29
Show Gist options
  • Save logrusorgru/abd846adb521a6fb39c7405f32fec0cf to your computer and use it in GitHub Desktop.
Save logrusorgru/abd846adb521a6fb39c7405f32fec0cf to your computer and use it in GitHub Desktop.
Golang load HTML templates
//
// 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
Copy link

Maybe we should use: string(os.PathSeparator).

@logrusorgru
Copy link
Author

Yep

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment