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
master
Buddy Sandidge 3 years ago
parent 98383235f9
commit fa1dca3fd6

1
.gitignore vendored

@ -0,0 +1 @@
coverage/

@ -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

@ -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
)

@ -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=

@ -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
}
func walk(files fs.FS, opts Options, handle func(string, []byte) error) error {
walker := func(path string, d fs.DirEntry, er error) (err error) {
// 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 (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 d.IsDir() {
return nil
}
if !matches {
if !strings.HasSuffix(path, t.Suffix) {
return nil
}
body, err := readfile(files, path)
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
}
return handle(t.trim(path), body)
})
}
if err := fs.WalkDir(files, opts.getRoot(), walker); err != nil {
return err
func (t Templates) getRoot() string {
if t.Root == "" {
return "."
}
return nil
return filepath.Clean(t.Root)
}
func readfile(files fs.FS, path string) ([]byte, error) {
f, err := files.Open(path)
if err != nil {
return nil, err
}
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
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, ".")
}

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

@ -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>

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

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

@ -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), " ")
}

@ -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))
})
}
}

@ -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))
})
}
}

@ -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)
}

@ -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 ""
}
}
Loading…
Cancel
Save