Convert POC package to tested version

Includes a Templates struct as a helper to generate templates using an
io/fs filesystem interface. This adds options not available in the
standard ParseFS provided by the Go standard library.

 • Only the base name is used for the name of templates. This means you
   can only look up one themplate by name from two different
   directories. For example, if directory "a" and directory "b" both
   have a template index.html, there is no way to specify using
   index.html for "a" or from "b"
 • Supports stripping a root directory and trimming a suffix to make
   importing templates easier

Includes two helper functions
 • AsString takes a template with data and executes the template and
   returns a strin.
 • String is like AsString, but will ignore errors
This commit is contained in:
Buddy Sandidge 2021-08-01 21:09:23 -07:00
parent 98383235f9
commit fa1dca3fd6
28 changed files with 434 additions and 77 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
coverage/

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
SHELL = bash
.PHONY: coverage
test:
go test ./...
coverage:
[[ -d coverage ]] || mkdir coverage
go test -coverpkg=./... -coverprofile=coverage/index.out ./...
go tool cover -html=coverage/index.out

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module git.buddy.wtf/lib/tmpl
go 1.16
require (
github.com/google/go-cmp v0.5.6
github.com/stretchr/testify v1.7.0
)

15
go.sum Normal file
View File

@ -0,0 +1,15 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,59 +1,31 @@
package main
package tmpl
import (
"html/template"
"io"
"io/fs"
"path/filepath"
"strings"
txttmpl "text/template"
"unicode/utf8"
)
// Options for getting a template tree
type Options struct {
Globs []string
Prefix string
// Templates for getting a template tree
type Templates struct {
Suffix string
Root string
Funcs map[string]interface{}
fs.FS
}
func (o Options) getRoot() string {
if o.Root == "" {
return "."
}
return o.Root
}
func (o Options) matchesGlob(path string) (bool, error) {
if len(o.Globs) == 0 {
return true, nil
}
for _, glob := range o.Globs {
ok, err := filepath.Match(glob, path)
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, nil
}
func (o Options) trim(path string) string {
return strings.TrimSuffix(strings.TrimPrefix(path, o.Prefix), o.Suffix)
}
// Templates returns templates based on a filesystem
func Templates(files fs.FS, opts Options) (*template.Template, error) {
// HTML returns templates based on a filesystem
func (t Templates) HTML(name string) (*template.Template, error) {
var ret *template.Template
parseTemplates := func(name string, body []byte) error {
var tmpl *template.Template
if ret == nil {
tmpl = template.New(name)
if opts.Funcs != nil {
tmpl = tmpl.Funcs(template.FuncMap(opts.Funcs))
if t.Funcs != nil {
tmpl = tmpl.Funcs(template.FuncMap(t.Funcs))
}
} else {
tmpl = ret.New(name)
@ -65,22 +37,24 @@ func Templates(files fs.FS, opts Options) (*template.Template, error) {
ret = tmpl
return nil
}
if err := walk(files, opts, parseTemplates); err != nil {
if err := t.walk(parseTemplates); err != nil {
return nil, err
}
if t := ret.Lookup(name); t != nil {
return t, nil
}
return ret, nil
}
// TextTemplates returns templates based on a filesystem
func TextTemplates(files fs.FS, opts Options) (*txttmpl.Template, error) {
// Text returns templates based on a filesystem
func (t Templates) Text(name string) (*txttmpl.Template, error) {
var ret *txttmpl.Template
parseTemplates := func(name string, body []byte) error {
var tmpl *txttmpl.Template
if ret == nil {
tmpl = txttmpl.New(name)
if opts.Funcs != nil {
tmpl = tmpl.Funcs(txttmpl.FuncMap(opts.Funcs))
if t.Funcs != nil {
tmpl = tmpl.Funcs(txttmpl.FuncMap(t.Funcs))
}
} else {
tmpl = ret.New(name)
@ -93,54 +67,60 @@ func TextTemplates(files fs.FS, opts Options) (*txttmpl.Template, error) {
return nil
}
if err := walk(files, opts, parseTemplates); err != nil {
if err := t.walk(parseTemplates); err != nil {
return nil, err
}
if t := ret.Lookup(name); t != nil {
return t, nil
}
return ret, nil
}
// List returns a list of files
func (t Templates) List() ([]string, error) {
ret := []string{}
err := t.walk(func(name string, _ []byte) error {
ret = append(ret, name)
return nil
})
if err != nil {
return nil, err
}
return ret, nil
}
func walk(files fs.FS, opts Options, handle func(string, []byte) error) error {
walker := func(path string, d fs.DirEntry, er error) (err error) {
func (t Templates) walk(handle func(string, []byte) error) error {
return fs.WalkDir(t.FS, t.getRoot(), func(path string, d fs.DirEntry, er error) (err error) {
if er != nil {
return er
}
matches, err := opts.matchesGlob(path)
if err != nil {
return err
}
if !matches {
if d.IsDir() {
return nil
}
body, err := readfile(files, path)
if !strings.HasSuffix(path, t.Suffix) {
return nil
}
body, err := fs.ReadFile(t, path)
if err != nil {
return err
}
if err := handle(opts.trim(path), body); err != nil {
return err
if !utf8.Valid(body) {
return nil
}
return nil
}
if err := fs.WalkDir(files, opts.getRoot(), walker); err != nil {
return err
}
return nil
return handle(t.trim(path), body)
})
}
func readfile(files fs.FS, path string) ([]byte, error) {
f, err := files.Open(path)
if err != nil {
return nil, err
func (t Templates) getRoot() string {
if t.Root == "" {
return "."
}
body, err := io.ReadAll(f)
if err != nil {
if err := f.Close(); err != nil {
return nil, err
}
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
return body, nil
}
return filepath.Clean(t.Root)
}
func (t Templates) trim(path string) string {
cleaned := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(t.Root))
cleaned = strings.TrimPrefix(cleaned, "/")
cleaned = strings.TrimSuffix(cleaned, t.Suffix)
return strings.TrimSuffix(cleaned, ".")
}

View File

@ -0,0 +1 @@
{{ invalid go template

View File

@ -0,0 +1 @@
{{ return_error }}

View File

@ -0,0 +1 @@
bar template: {{ . | upper -}}

View File

@ -0,0 +1 @@
foo template: {{ . -}}

View File

View File

View File

@ -0,0 +1,3 @@
## First
Foo: {{ template "first/1/foo.txt" . }}
Bar: {{ template "first/1/bar.txt" . -}}

View File

View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>test</title>
</head>
<body>
{{ template "second/index" . -}}
</body>
</html>

View File

@ -0,0 +1,2 @@
# root index
{{ template "first/index.txt" . }}

View File

@ -0,0 +1,3 @@
<li>
<ul>{{- . | upper -}}</ul>
</li>

View File

@ -0,0 +1 @@
<span>single: <a>{{ . }}</a></span>

View File

@ -0,0 +1,9 @@
<section>
<div>
{{ template "second/1/list" . }}
</div>
<div>
{{ template "second/1/single" . }}
</div>
</section>

View File

BIN
tests/data/tiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>test</title>
</head>
<body>
<section>
<div>
<li>
<ul>TEST</ul>
</li>
</div>
<div>
<span>single: <a>test</a></span>
</div>
</section>
</body>
</html>

View File

@ -0,0 +1,4 @@
# root index
## First
Foo: foo template: test
Bar: bar template: TEST

View File

@ -0,0 +1,4 @@
# root index
## First
Foo: foo template: test
Bar: bar template: TEST

88
tests/html_test.go Normal file
View File

@ -0,0 +1,88 @@
package templtest
import (
_ "embed"
"errors"
"regexp"
"strings"
"testing"
"git.buddy.wtf/lib/tmpl"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
)
//go:embed expect/index-simple.html
var expectdIndexHTML string
func TestHTML(t *testing.T) {
type testcase struct {
Name string
tmpl.Templates
Expected string
Template string
Data interface{}
Err bool
}
tt := []testcase{
{
Name: "get template by name",
Expected: expectdIndexHTML,
Template: "index",
Data: "test",
Templates: tmpl.Templates{
FS: data,
Root: "data",
Suffix: "html.tmpl",
Funcs: map[string]interface{}{
"upper": strings.ToUpper,
},
},
},
{
Name: "error on invalid template",
Err: true,
Template: "borked",
Templates: tmpl.Templates{FS: data, Suffix: "invalid"},
},
{
Name: "error calling invalid template",
Template: "borked",
Templates: tmpl.Templates{
FS: data,
Suffix: "template",
Funcs: map[string]interface{}{
"return_error": func() (string, error) {
return "invalid", errors.New("error running template")
},
},
},
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
a := assert.New(t)
template, err := tc.Templates.HTML(tc.Template)
if tc.Err {
a.Error(err)
return
} else {
a.NoError(err)
}
actual := tmpl.String(template, tc.Data)
a.Empty(cmp.Diff(singlespace(actual), singlespace(tc.Expected)))
})
}
}
var spacesRegex = regexp.MustCompile(`\s+`)
func singlespace(in string) string {
return strings.Join(spacesRegex.Split(in, -1), " ")
}

73
tests/list_test.go Normal file
View File

@ -0,0 +1,73 @@
package templtest
import (
"testing"
"git.buddy.wtf/lib/tmpl"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
)
func TestList(t *testing.T) {
type testcase struct {
Name string
tmpl.Templates
Expected []string
Err error
}
tt := []testcase{
{
Name: "list all files on empty config ",
Templates: tmpl.Templates{FS: data},
Expected: []string{
"data/broken.invalid",
"data/broken.template",
"data/first/1/bar.txt.tmpl",
"data/first/1/foo.txt.tmpl",
"data/first/2/item.txt.tmpl",
"data/first/2/list.txt.tmpl",
"data/first/index.txt.tmpl",
"data/first/static.txt",
"data/index.html.tmpl",
"data/index.txt.tmpl",
"data/second/1/list.html.tmpl",
"data/second/1/single.html.tmpl",
"data/second/index.html.tmpl",
"data/second/static.txt",
},
},
{
Name: "only list templates with matching suffix with suffix not in name",
Templates: tmpl.Templates{
FS: data,
Root: "data",
Suffix: "tmpl",
},
Expected: []string{
"first/1/bar.txt",
"first/1/foo.txt",
"first/2/item.txt",
"first/2/list.txt",
"first/index.txt",
"index.html",
"index.txt",
"second/1/list.html",
"second/1/single.html",
"second/index.html",
},
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
a := assert.New(t)
actual, err := tc.Templates.List()
errorIs(a, err, tc.Err)
a.Empty(cmp.Diff(tc.Expected, actual))
})
}
}

81
tests/text_test.go Normal file
View File

@ -0,0 +1,81 @@
package templtest
import (
_ "embed"
"errors"
"strings"
"testing"
"git.buddy.wtf/lib/tmpl"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
)
//go:embed expect/root-text-template.txt
var expectdRootText string
func TestText(t *testing.T) {
type testcase struct {
Name string
tmpl.Templates
Expected string
Template string
Data interface{}
Err bool
}
tt := []testcase{
{
Name: "get template by name",
Expected: expectdRootText,
Template: "index.txt",
Data: "test",
Templates: tmpl.Templates{
FS: data,
Root: "data",
Suffix: "tmpl",
Funcs: map[string]interface{}{
"upper": strings.ToUpper,
},
},
},
{
Name: "error on invalid template",
Err: true,
Template: "broken",
Templates: tmpl.Templates{FS: data, Suffix: "invalid"},
},
{
Name: "error calling invalid template",
Template: "borked",
Templates: tmpl.Templates{
FS: data,
Suffix: "template",
Funcs: map[string]interface{}{
"return_error": func() (string, error) {
return "invalid", errors.New("error running template")
},
},
},
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
a := assert.New(t)
template, err := tc.Templates.Text(tc.Template)
if tc.Err {
a.Error(err)
return
} else {
a.NoError(err)
}
actual := tmpl.String(template, tc.Data)
a.Empty(cmp.Diff(actual, tc.Expected))
})
}
}

15
tests/utils.go Normal file
View File

@ -0,0 +1,15 @@
package templtest
import (
"embed"
"errors"
)
//go:embed data
var data embed.FS
func errorIs(a interface {
Truef(value bool, msg string, args ...interface{}) bool
}, actual, expected error) {
a.Truef(errors.Is(actual, expected), "expected error %v to be %v", actual, expected)
}

28
utils.go Normal file
View File

@ -0,0 +1,28 @@
package tmpl
import (
"bytes"
"io"
)
type Executor interface {
Execute(wr io.Writer, data interface{}) error
}
// AsString to convert template to string
func AsString(t Executor, data interface{}) (string, error) {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// String to convert template to string and ignores errors
func String(t Executor, data interface{}) string {
if result, err := AsString(t, data); err == nil {
return result
} else {
return ""
}
}