diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4bab277 --- /dev/null +++ b/Makefile @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b41b956 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f212493 --- /dev/null +++ b/go.sum @@ -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= diff --git a/templates.go b/templates.go index fe38919..f03e456 100644 --- a/templates.go +++ b/templates.go @@ -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 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 -} \ No newline at end of file +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, ".") +} diff --git a/tests/data/broken.invalid b/tests/data/broken.invalid new file mode 100644 index 0000000..96c1122 --- /dev/null +++ b/tests/data/broken.invalid @@ -0,0 +1 @@ +{{ invalid go template diff --git a/tests/data/broken.template b/tests/data/broken.template new file mode 100644 index 0000000..07df186 --- /dev/null +++ b/tests/data/broken.template @@ -0,0 +1 @@ +{{ return_error }} diff --git a/tests/data/first/1/bar.txt.tmpl b/tests/data/first/1/bar.txt.tmpl new file mode 100644 index 0000000..24e3df5 --- /dev/null +++ b/tests/data/first/1/bar.txt.tmpl @@ -0,0 +1 @@ +bar template: {{ . | upper -}} diff --git a/tests/data/first/1/foo.txt.tmpl b/tests/data/first/1/foo.txt.tmpl new file mode 100644 index 0000000..0dc7d54 --- /dev/null +++ b/tests/data/first/1/foo.txt.tmpl @@ -0,0 +1 @@ +foo template: {{ . -}} diff --git a/tests/data/first/2/item.txt.tmpl b/tests/data/first/2/item.txt.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/first/2/list.txt.tmpl b/tests/data/first/2/list.txt.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/first/index.txt.tmpl b/tests/data/first/index.txt.tmpl new file mode 100644 index 0000000..d11156e --- /dev/null +++ b/tests/data/first/index.txt.tmpl @@ -0,0 +1,3 @@ +## First +Foo: {{ template "first/1/foo.txt" . }} +Bar: {{ template "first/1/bar.txt" . -}} diff --git a/tests/data/first/static.txt b/tests/data/first/static.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/index.html.tmpl b/tests/data/index.html.tmpl new file mode 100644 index 0000000..7ea941a --- /dev/null +++ b/tests/data/index.html.tmpl @@ -0,0 +1,9 @@ + + + + test + + + {{ template "second/index" . -}} + + diff --git a/tests/data/index.txt.tmpl b/tests/data/index.txt.tmpl new file mode 100644 index 0000000..b61ab2d --- /dev/null +++ b/tests/data/index.txt.tmpl @@ -0,0 +1,2 @@ +# root index +{{ template "first/index.txt" . }} diff --git a/tests/data/second/1/list.html.tmpl b/tests/data/second/1/list.html.tmpl new file mode 100644 index 0000000..9a44829 --- /dev/null +++ b/tests/data/second/1/list.html.tmpl @@ -0,0 +1,3 @@ +
  • + +
  • diff --git a/tests/data/second/1/single.html.tmpl b/tests/data/second/1/single.html.tmpl new file mode 100644 index 0000000..5a241d8 --- /dev/null +++ b/tests/data/second/1/single.html.tmpl @@ -0,0 +1 @@ +single: {{ . }} diff --git a/tests/data/second/index.html.tmpl b/tests/data/second/index.html.tmpl new file mode 100644 index 0000000..0b6fe47 --- /dev/null +++ b/tests/data/second/index.html.tmpl @@ -0,0 +1,9 @@ +
    +
    + {{ template "second/1/list" . }} +
    +
    + {{ template "second/1/single" . }} +
    +
    + diff --git a/tests/data/second/static.txt b/tests/data/second/static.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/tiny.png b/tests/data/tiny.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/tests/data/tiny.png differ diff --git a/tests/expect/index-simple.html b/tests/expect/index-simple.html new file mode 100644 index 0000000..317f425 --- /dev/null +++ b/tests/expect/index-simple.html @@ -0,0 +1,18 @@ + + + + test + + +
    +
    +
  • +
      TEST
    +
  • +
    +
    + single: test +
    +
    + + diff --git a/tests/expect/root-text-template.txt b/tests/expect/root-text-template.txt new file mode 100644 index 0000000..33ee941 --- /dev/null +++ b/tests/expect/root-text-template.txt @@ -0,0 +1,4 @@ +# root index +## First +Foo: foo template: test +Bar: bar template: TEST diff --git a/tests/expect/rootTextTemplate.txt b/tests/expect/rootTextTemplate.txt new file mode 100644 index 0000000..33ee941 --- /dev/null +++ b/tests/expect/rootTextTemplate.txt @@ -0,0 +1,4 @@ +# root index +## First +Foo: foo template: test +Bar: bar template: TEST diff --git a/tests/html_test.go b/tests/html_test.go new file mode 100644 index 0000000..ac5deb2 --- /dev/null +++ b/tests/html_test.go @@ -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), " ") +} diff --git a/tests/list_test.go b/tests/list_test.go new file mode 100644 index 0000000..9acc803 --- /dev/null +++ b/tests/list_test.go @@ -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)) + }) + } +} diff --git a/tests/text_test.go b/tests/text_test.go new file mode 100644 index 0000000..b76b1d5 --- /dev/null +++ b/tests/text_test.go @@ -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)) + }) + } +} diff --git a/tests/utils.go b/tests/utils.go new file mode 100644 index 0000000..863c156 --- /dev/null +++ b/tests/utils.go @@ -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) +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..9f09077 --- /dev/null +++ b/utils.go @@ -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 "" + } +}