diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2551ca4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + name: Golang-CI (lint) + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 # action page: + with: + go-version: stable + + - name: Run linter + uses: golangci/golangci-lint-action@v3.6.0 + with: + version: v1.53 + args: -v --build-tags=race --timeout=5m + + test: + needs: lint + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v4 # action page: + with: + go-version: stable + + - name: Check out code + uses: actions/checkout@v3 + with: + ref: ${{ github.ref }} + + - name: Init Go modules Cache # Docs: + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: ${{ runner.os }}-go- + + - name: Install Go dependencies + run: go mod download + + - name: Run Unit tests + run: go test -race -covermode=atomic -coverprofile /tmp/coverage.txt . + + - name: Upload Coverage report to CodeCov + continue-on-error: true + uses: codecov/codecov-action@v3.1.4 # https://github.com/codecov/codecov-action + with: + file: /tmp/coverage.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fe355a --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +**/.DS_Store +**/._.DS_Store +**/.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8135a5e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 (GO) Wool + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a44bb66 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Extends HTML Template + +[![Build Status](https://github.com/gowool/extends-template/workflows/Tests/badge.svg?branch=main)](https://github.com/gowool/extends-template/actions?query=branch%3Amain) +[![codecov](https://codecov.io/gh/gowool/extends-template/branch/main/graph/badge.svg)](https://codecov.io/gh/gowool/extends-template) +[![License](https://img.shields.io/dub/l/vibe-d.svg)](https://github.com/gowool/extends-template/blob/main/LICENSE) + +## Installation + +```sh +go get github.com/gowool/extends-template +``` + +## License + +Distributed under MIT License, please see license file within the code for more details. diff --git a/common.go b/common.go new file mode 100644 index 0000000..99e0594 --- /dev/null +++ b/common.go @@ -0,0 +1,59 @@ +package et + +import ( + "crypto/sha256" + "fmt" + "reflect" + "regexp" + "unsafe" +) + +const ( + extendsPattern = `^%s\s*extends\s*"(.*?)"\s*%s` + templatePattern = `%s\s*template\s*"(.*?)".*?%s` +) + +func ReExtends(left, right string) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf(extendsPattern, left, right)) +} + +func ReTemplate(left, right string) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf(templatePattern, left, right)) +} + +func merge(left error, right error) error { + switch { + case left == nil: + return right + case right == nil: + return left + } + + return fmt.Errorf("%w; %w", left, right) +} + +func errorf(right error, format string, a ...any) error { + return merge(fmt.Errorf(format, a...), right) +} + +func typeName(i any) string { + t := reflect.TypeOf(i) + + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + return t.Name() +} + +func toBytes(s string) []byte { + return unsafe.Slice(unsafe.StringData(s), len(s)) +} + +func toString(b []byte) string { + return *(*string)(unsafe.Pointer(&b)) +} + +func hash(data []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(data)) +} diff --git a/environment.go b/environment.go new file mode 100644 index 0000000..e17bf24 --- /dev/null +++ b/environment.go @@ -0,0 +1,145 @@ +package et + +import ( + "bytes" + "context" + "fmt" + "html/template" + "regexp" + "strconv" + "sync" + "sync/atomic" +) + +const ( + leftDelim = "{{" + rightDelim = "}}" +) + +type Environment struct { + debug bool + global []string + left string + right string + loader Loader + reExtends *regexp.Regexp + reTemplates *regexp.Regexp + templates *sync.Map + funcMap template.FuncMap + hash atomic.Value + mu sync.Mutex +} + +func NewEnvironment(loader Loader) *Environment { + e := &Environment{loader: loader} + + return e.Delims(leftDelim, rightDelim) +} + +func (e *Environment) Debug(debug bool) *Environment { + e.mu.Lock() + defer e.mu.Unlock() + + if e.debug != debug { + e.debug = debug + e.updateHash() + } + + return e +} + +func (e *Environment) Delims(left, right string) *Environment { + e.mu.Lock() + defer e.mu.Unlock() + + e.left = left + e.right = right + e.reExtends = ReExtends(left, right) + e.reTemplates = ReTemplate(left, right) + e.updateHash() + + return e +} + +func (e *Environment) Funcs(funcMap template.FuncMap) *Environment { + e.mu.Lock() + defer e.mu.Unlock() + + e.funcMap = template.FuncMap{} + for k, v := range funcMap { + e.funcMap[k] = v + } + e.updateHash() + + return e +} + +func (e *Environment) Global(global ...string) *Environment { + e.mu.Lock() + defer e.mu.Unlock() + + e.global = append(make([]string, 0, len(global)), global...) + e.updateHash() + + return e +} + +func (e *Environment) NewHTMLTemplate(name string) *template.Template { + return template.New(name).Delims(e.left, e.right).Funcs(e.funcMap) +} + +func (e *Environment) NewTemplateWrapper(name string) *TemplateWrapper { + return NewTemplateWrapper( + e.NewHTMLTemplate(name), + e.loader, + e.reExtends, + e.reTemplates, + e.global..., + ) +} + +func (e *Environment) Load(ctx context.Context, name string) (*TemplateWrapper, error) { + e.mu.Lock() + defer e.mu.Unlock() + + var wrapper *TemplateWrapper + + key := e.key(name) + + v, ok := e.templates.Load(key) + if ok { + wrapper = v.(*TemplateWrapper) + } else { + wrapper = e.NewTemplateWrapper(name) + + e.templates.Store(key, wrapper) + } + + if !ok || e.debug || !wrapper.IsFresh(ctx) { + if err := wrapper.Parse(ctx); err != nil { + return nil, err + } + } + return wrapper, nil +} + +func (e *Environment) updateHash() { + var buf bytes.Buffer + + buf.WriteString(e.left) + buf.WriteString(e.right) + buf.WriteString(strconv.FormatBool(e.debug)) + for _, s := range e.global { + buf.WriteString(s) + } + for name, _ := range e.funcMap { + buf.WriteString(name) + } + + e.hash.Store(hash(buf.Bytes())) + e.templates = &sync.Map{} +} + +func (e *Environment) key(name string) string { + return hash(toBytes(fmt.Sprintf("%s:%s", name, e.hash.Load()))) +} diff --git a/environment_test.go b/environment_test.go new file mode 100644 index 0000000..f973bc6 --- /dev/null +++ b/environment_test.go @@ -0,0 +1,70 @@ +package et_test + +import ( + et "github.com/gowool/extends-template" + "github.com/stretchr/testify/assert" + "html/template" + "testing" + "time" +) + +func TestEnvironment_Debug(t *testing.T) { + env := et.NewEnvironment(wrapLoader{}) + for _, d := range []bool{true, true, false, false} { + assert.NotNil(t, env.Debug(d)) + } +} + +func TestEnvironment_Funcs(t *testing.T) { + env := et.NewEnvironment(wrapLoader{}) + + assert.NotNil(t, env.Funcs(template.FuncMap{"test": func() {}})) +} + +func TestEnvironment_Global(t *testing.T) { + env := et.NewEnvironment(wrapLoader{}) + + assert.NotNil(t, env.Global("t1.html", "t2.html")) +} + +func TestEnvironment_Load(t *testing.T) { + env := et.NewEnvironment(wrapLoader{t: time.Now().Unix()}) + + scenarios := []struct { + view string + isError bool + }{ + { + view: "no-view.html", + isError: true, + }, + { + view: "@main/no-view.html", + isError: true, + }, + { + view: "@main/view.html", + isError: false, + }, + { + view: "@main/layout.html", + isError: false, + }, + } + + for _, s := range scenarios { + for range []struct{}{{}, {}} { + w, err := env.Load(nil, s.view) + if s.isError { + assert.Nil(t, w) + assert.Error(t, err) + } else { + assert.NotNil(t, w) + assert.Nil(t, err) + if assert.NotNil(t, w.HTML) { + assert.Equal(t, s.view, w.HTML.Name()) + } + } + } + } +} diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..c05b452 --- /dev/null +++ b/example/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + et "github.com/gowool/extends-template" + "html/template" + "os" + "path/filepath" +) + +func main() { + p, err := filepath.Abs("tests") + if err != nil { + panic(err) + } + println(p) + + fsys := os.DirFS(p) + fsLoader := et.NewFilesystemLoader(fsys) + + if err = fsLoader.SetPaths("test_ns", "main", "base"); err != nil { + panic(err) + } + + loader := et.NewChainLoader(fsLoader) + + e := et.NewEnvironment(loader) + e.Debug(true).Funcs(template.FuncMap{ + "raw": func(s string) template.HTML { + return template.HTML(s) + }, + }) + + view := "@test_ns/views/home.html" + + w, err := e.Load(context.TODO(), view) + if err != nil { + panic(err) + } + + if err = w.HTML.ExecuteTemplate(os.Stdout, view, nil); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95fecd0 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/gowool/extends-template + +go 1.20 + +require ( + github.com/stretchr/testify v1.8.4 + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de93d19 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..fa5c8f3 --- /dev/null +++ b/loader.go @@ -0,0 +1,25 @@ +package et + +import "context" + +const ( + ErrNotDefinedFormat = "template \"%s\" is not defined" + ErrDirNotExistsFormat = "the \"%s\" directory does not exist" +) + +type Source struct { + Code []byte + Name string + File string +} + +type Loader interface { + // Get returns a Source for a given template name + Get(ctx context.Context, name string) (*Source, error) + + // IsFresh check if template is fresh + IsFresh(ctx context.Context, name string, t int64) (bool, error) + + // Exists check if template exists + Exists(ctx context.Context, name string) (bool, error) +} diff --git a/loader_chain.go b/loader_chain.go new file mode 100644 index 0000000..19e5ae4 --- /dev/null +++ b/loader_chain.go @@ -0,0 +1,98 @@ +package et + +import ( + "context" + "fmt" + "sync" +) + +var _ Loader = (*ChainLoader)(nil) + +type ChainLoader struct { + loaders []Loader + cache *sync.Map + mu sync.RWMutex +} + +func NewChainLoader(loaders ...Loader) *ChainLoader { + return &ChainLoader{ + loaders: loaders, + cache: new(sync.Map), + } +} + +func (l *ChainLoader) Add(loader Loader) *ChainLoader { + l.mu.Lock() + defer l.mu.Unlock() + + l.loaders = append(l.loaders, loader) + l.cache = new(sync.Map) + + return l +} + +func (l *ChainLoader) Loaders() (loaders []Loader) { + l.mu.RLock() + defer l.mu.RUnlock() + + loaders = append(make([]Loader, 0, len(l.loaders)), l.loaders...) + return +} + +func (l *ChainLoader) Get(ctx context.Context, name string) (*Source, error) { + r, err := l.loop(ctx, name, func(loader Loader) (any, error) { + return loader.Get(ctx, name) + }) + if err != nil { + return nil, err + } + return r.(*Source), nil +} + +func (l *ChainLoader) IsFresh(ctx context.Context, name string, t int64) (bool, error) { + r, err := l.loop(ctx, name, func(loader Loader) (any, error) { + return loader.IsFresh(ctx, name, t) + }) + if err != nil { + return false, err + } + return r.(bool), nil +} + +func (l *ChainLoader) Exists(ctx context.Context, name string) (bool, error) { + if r, ok := l.cache.Load(name); ok { + return r.(bool), nil + } + r, err := l.loop(ctx, name, func(loader Loader) (any, error) { + return loader.Exists(ctx, name) + }) + if err != nil { + l.cache.Store(name, false) + return false, err + } + + l.cache.Store(name, r.(bool)) + return r.(bool), nil +} + +func (l *ChainLoader) loop(ctx context.Context, name string, fn func(loader Loader) (any, error)) (any, error) { + l.mu.RLock() + defer l.mu.RUnlock() + + var err error + + for _, loader := range l.loaders { + if ok, err1 := loader.Exists(ctx, name); !ok { + err = merge(err, err1) + continue + } + + if r, err1 := fn(loader); err1 == nil { + return r, nil + } else { + err = merge(err, fmt.Errorf("[%s]: %w", typeName(loader), err1)) + } + } + + return nil, errorf(err, ErrNotDefinedFormat, name) +} diff --git a/loader_chain_test.go b/loader_chain_test.go new file mode 100644 index 0000000..f9b2d33 --- /dev/null +++ b/loader_chain_test.go @@ -0,0 +1,160 @@ +package et_test + +import ( + "context" + "errors" + et "github.com/gowool/extends-template" + "github.com/stretchr/testify/assert" + "testing" +) + +const ( + f1 = "file1.html" + f2 = "file2.html" +) + +func TestChainLoader_Add(t *testing.T) { + chain := et.NewChainLoader() + c := chain.Add(loader1{}) + + assert.NotNil(t, c) +} + +func TestChainLoader_Loaders(t *testing.T) { + chain := et.NewChainLoader() + + assert.Len(t, chain.Loaders(), 0) + + chain.Add(loader1{}) + + assert.Len(t, chain.Loaders(), 1) + + chain.Add(loader2{}) + + assert.Len(t, chain.Loaders(), 2) +} + +func TestChainLoader_Get(t *testing.T) { + loader := et.NewChainLoader(loader1{}, loader2{}) + + scenarios := []struct { + view string + isError bool + }{ + { + view: "no-file.html", + isError: true, + }, + { + view: f1, + isError: false, + }, + { + view: f2, + isError: false, + }, + } + + for _, s := range scenarios { + source, err := loader.Get(nil, s.view) + + if s.isError { + assert.Nil(t, source) + assert.Error(t, err) + } else if assert.NotNil(t, source) && assert.Nil(t, err) { + assert.Equal(t, s.view, source.Name) + assert.Equal(t, memData[s.view], source.Code) + assert.Empty(t, source.File) + } + } +} + +func TestChainLoader_IsFresh(t *testing.T) { + loader := et.NewChainLoader(loader1{}, loader2{}) + + scenarios := []struct { + view string + expected bool + }{ + { + view: "no-file.html", + expected: false, + }, + { + view: f1, + expected: true, + }, + { + view: f2, + expected: true, + }, + } + + for _, s := range scenarios { + isFresh, _ := loader.IsFresh(nil, s.view, 0) + + assert.Equal(t, s.expected, isFresh) + } +} + +func TestChainLoader_Exists(t *testing.T) { + loader := et.NewChainLoader(loader1{}, loader2{}) + + scenarios := []struct { + view string + expected bool + }{ + { + view: "no-file.html", + expected: false, + }, + { + view: f1, + expected: true, + }, + { + view: f2, + expected: true, + }, + } + + for _, s := range scenarios { + isFresh, _ := loader.Exists(nil, s.view) + + assert.Equal(t, s.expected, isFresh) + } +} + +type loader1 struct{} + +func (loader1) Get(_ context.Context, name string) (*et.Source, error) { + if name == f1 { + return &et.Source{Name: f1}, nil + } + return nil, errors.New("not found") +} + +func (loader1) IsFresh(_ context.Context, name string, _ int64) (bool, error) { + return name == f1, nil +} + +func (loader1) Exists(_ context.Context, name string) (bool, error) { + return name == f1, nil +} + +type loader2 struct{} + +func (loader2) Get(_ context.Context, name string) (*et.Source, error) { + if name == f2 { + return &et.Source{Name: f2}, nil + } + return nil, errors.New("not found") +} + +func (loader2) IsFresh(_ context.Context, name string, _ int64) (bool, error) { + return name == f2, nil +} + +func (loader2) Exists(_ context.Context, name string) (bool, error) { + return name == f2, nil +} diff --git a/loader_filesystem.go b/loader_filesystem.go new file mode 100644 index 0000000..4883aef --- /dev/null +++ b/loader_filesystem.go @@ -0,0 +1,220 @@ +package et + +import ( + "context" + "fmt" + "golang.org/x/exp/slices" + "io/fs" + "path/filepath" + "strings" + "sync" +) + +var _ Loader = (*FilesystemLoader)(nil) + +const BaseNamespace = "base" + +var sep = fmt.Sprintf("%c", filepath.Separator) + +type FilesystemLoader struct { + fsys fs.FS + paths *sync.Map + errors *sync.Map + cache *sync.Map + mu sync.Mutex +} + +func NewFilesystemLoader(fsys fs.FS) *FilesystemLoader { + return &FilesystemLoader{ + fsys: fsys, + paths: new(sync.Map), + errors: new(sync.Map), + cache: new(sync.Map), + } +} + +func (l *FilesystemLoader) Namespaces() (namespaces []string) { + l.mu.Lock() + defer l.mu.Unlock() + + l.paths.Range(func(key, _ any) bool { + namespaces = append(namespaces, key.(string)) + return true + }) + return +} + +func (l *FilesystemLoader) Paths(namespace string) (paths []string) { + l.mu.Lock() + defer l.mu.Unlock() + + if data, ok := l.paths.Load(namespace); ok { + paths = append(make([]string, 0, len(data.([]string))), data.([]string)...) + } + return +} + +func (l *FilesystemLoader) BasePrepend(path string) error { + return l.Prepend(BaseNamespace, path) +} + +func (l *FilesystemLoader) BaseAppend(path string) error { + return l.Append(BaseNamespace, path) +} + +func (l *FilesystemLoader) Prepend(namespace, p string) (err error) { + l.mu.Lock() + defer l.mu.Unlock() + + l.reset() + + if p, err = l.path(p); err != nil { + return + } + + var paths []string + if data, ok := l.paths.Load(namespace); ok { + paths = slices.Insert(data.([]string), 0, p) + } else { + paths = []string{p} + } + + l.paths.Store(namespace, paths) + return +} + +func (l *FilesystemLoader) Append(namespace, p string) error { + l.mu.Lock() + defer l.mu.Unlock() + + l.reset() + + return l.add(namespace, p) +} + +func (l *FilesystemLoader) SetPaths(namespace string, paths ...string) error { + l.mu.Lock() + defer l.mu.Unlock() + + l.reset() + l.paths.Store(namespace, make([]string, 0, len(paths))) + + var err error + for _, p := range paths { + err = merge(err, l.add(namespace, p)) + } + return err +} + +func (l *FilesystemLoader) Get(_ context.Context, name string) (*Source, error) { + l.mu.Lock() + defer l.mu.Unlock() + + file, err := l.find(name) + if err != nil { + return nil, err + } + + code, err := fs.ReadFile(l.fsys, file) + if err != nil { + return nil, err + } + + return &Source{Name: name, Code: code, File: file}, nil +} + +func (l *FilesystemLoader) IsFresh(_ context.Context, name string, t int64) (bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + + file, err := l.find(name) + if err != nil { + return false, err + } + + if stat, err := fs.Stat(l.fsys, file); err != nil { + return false, err + } else { + return stat.ModTime().Unix() < t, nil + } +} + +func (l *FilesystemLoader) Exists(_ context.Context, name string) (bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + + _, err := l.find(name) + return err == nil, err +} + +func (l *FilesystemLoader) find(name string) (string, error) { + if p, ok := l.cache.Load(name); ok { + return p.(string), nil + } + + if err, ok := l.errors.Load(name); ok { + return "", err.(error) + } + + namespace, shortname := l.parse(name) + + var err error + if paths, ok := l.paths.Load(namespace); ok { + for _, p := range paths.([]string) { + file := filepath.Join(p, shortname) + if _, err1 := fs.Stat(l.fsys, file); err1 == nil { + l.cache.Store(name, file) + return file, nil + } + } + err = fmt.Errorf("unable to find template \"%s\" (looked into: %s)", name, strings.Join(paths.([]string), ", ")) + } else { + err = fmt.Errorf("there are no registered paths for namespace \"%s\"", namespace) + } + + l.errors.Store(name, err) + + return "", err +} + +func (l *FilesystemLoader) add(namespace, p string) (err error) { + if p, err = l.path(p); err != nil { + return + } + + var paths []string + if data, ok := l.paths.Load(namespace); ok { + paths = data.([]string) + } + + l.paths.Store(namespace, append(paths, p)) + return +} + +func (l *FilesystemLoader) normalize(s string) string { + return strings.ReplaceAll(s, "/", sep) +} + +func (l *FilesystemLoader) path(p string) (string, error) { + p = strings.Trim(l.normalize(p), sep) + + if stat, err := fs.Stat(l.fsys, p); err != nil { + return p, errorf(err, ErrDirNotExistsFormat, p) + } else if !stat.IsDir() { + return p, errorf(nil, ErrDirNotExistsFormat, p) + } + + return p, nil +} + +func (l *FilesystemLoader) parse(name string) (string, string) { + if data := strings.SplitN(name, "/", 2); len(data) == 2 && '@' == data[0][0] { + return data[0][1:], data[1] + } + return BaseNamespace, name +} + +func (l *FilesystemLoader) reset() { + l.errors = new(sync.Map) + l.cache = new(sync.Map) +} diff --git a/loader_filesystem_test.go b/loader_filesystem_test.go new file mode 100644 index 0000000..2f41708 --- /dev/null +++ b/loader_filesystem_test.go @@ -0,0 +1,176 @@ +package et_test + +import ( + et "github.com/gowool/extends-template" + "github.com/stretchr/testify/assert" + "os" + "testing" + "time" +) + +func newFilesystemLoader() *et.FilesystemLoader { + return et.NewFilesystemLoader(os.DirFS("./tests")) +} + +func TestFilesystemLoader_Namespaces(t *testing.T) { + expected := "base" + + loader := newFilesystemLoader() + + assert.Empty(t, loader.Namespaces()) + + _ = loader.BaseAppend(expected) + ns := loader.Namespaces() + + if assert.Len(t, ns, 1) { + assert.Equal(t, et.BaseNamespace, ns[0]) + } +} + +func TestFilesystemLoader_Prepend(t *testing.T) { + expected := "main" + + loader := newFilesystemLoader() + _ = loader.BaseAppend("base") + err := loader.BasePrepend(expected) + + if assert.NoError(t, err) { + paths := loader.Paths(et.BaseNamespace) + + assert.Len(t, paths, 2) + assert.Contains(t, paths, expected) + } +} + +func TestFilesystemLoader_AppendError(t *testing.T) { + loader := newFilesystemLoader() + + err := loader.BaseAppend("no-path") + + assert.Error(t, err) +} + +func TestFilesystemLoader_SetPaths(t *testing.T) { + expected := []string{"main", "base"} + + loader := newFilesystemLoader() + + assert.Empty(t, loader.Paths(et.BaseNamespace)) + + err := loader.SetPaths(et.BaseNamespace, expected...) + + if assert.NoError(t, err) { + paths := loader.Paths(et.BaseNamespace) + + assert.Len(t, paths, len(expected)) + + for _, p := range paths { + assert.Contains(t, expected, p) + } + } +} + +func TestFilesystemLoader_Get(t *testing.T) { + loader := newFilesystemLoader() + _ = loader.BaseAppend("base") + + scenarios := []struct { + view string + isError bool + }{ + { + view: "views/no-home.html", + isError: true, + }, + { + view: "views/home.html", + isError: false, + }, + } + + for _, s := range scenarios { + source, err := loader.Get(nil, s.view) + + if s.isError { + assert.Nil(t, source) + assert.Error(t, err) + } else if assert.NotNil(t, source) && assert.Nil(t, err) { + assert.Equal(t, s.view, source.Name) + } + } +} + +func TestFilesystemLoader_Exists(t *testing.T) { + loader := newFilesystemLoader() + _ = loader.BaseAppend("base") + + scenarios := []struct { + view string + expected bool + isError bool + }{ + { + view: "views/home.html", + expected: true, + isError: false, + }, + { + view: "views/no-home.html", + expected: false, + isError: true, + }, + } + + for _, s := range scenarios { + exists, err := loader.Exists(nil, s.view) + + assert.Equal(t, s.expected, exists) + if s.isError { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + } +} + +func TestFilesystemLoader_IsFresh(t *testing.T) { + loader := newFilesystemLoader() + _ = loader.BaseAppend("base") + + scenarios := []struct { + view string + t int64 + expected bool + isError bool + }{ + { + view: "views/no-home.html", + t: time.Now().Unix(), + expected: false, + isError: true, + }, + { + view: "views/home.html", + t: 0, + expected: false, + isError: false, + }, + { + view: "views/home.html", + t: time.Now().Unix(), + expected: true, + isError: false, + }, + } + + for _, s := range scenarios { + isFresh, err := loader.IsFresh(nil, s.view, s.t) + + assert.Equal(t, s.expected, isFresh) + if s.isError { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + } +} diff --git a/loader_memory.go b/loader_memory.go new file mode 100644 index 0000000..91d3934 --- /dev/null +++ b/loader_memory.go @@ -0,0 +1,46 @@ +package et + +import ( + "context" + "sync" +) + +var _ Loader = (*MemoryLoader)(nil) + +type MemoryLoader struct { + templates *sync.Map +} + +func NewMemoryLoader(templates map[string][]byte) *MemoryLoader { + l := &MemoryLoader{templates: new(sync.Map)} + + for name, code := range templates { + l.Add(name, code) + } + + return l +} + +func (l *MemoryLoader) Add(name string, code []byte) *MemoryLoader { + l.templates.Store(name, code) + + return l +} + +func (l *MemoryLoader) Get(_ context.Context, name string) (*Source, error) { + if code, ok := l.templates.Load(name); ok { + return &Source{Code: code.([]byte), Name: name}, nil + } + return nil, errorf(nil, ErrNotDefinedFormat, name) +} + +func (l *MemoryLoader) IsFresh(ctx context.Context, name string, _ int64) (bool, error) { + return l.Exists(ctx, name) +} + +func (l *MemoryLoader) Exists(_ context.Context, name string) (bool, error) { + if _, ok := l.templates.Load(name); ok { + return true, nil + } + return false, errorf(nil, ErrNotDefinedFormat, name) +} diff --git a/loader_memory_test.go b/loader_memory_test.go new file mode 100644 index 0000000..f257c76 --- /dev/null +++ b/loader_memory_test.go @@ -0,0 +1,116 @@ +package et_test + +import ( + et "github.com/gowool/extends-template" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +var memData = map[string][]byte{ + "file.html": []byte("

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

"), +} + +func TestMemoryLoader_Get(t *testing.T) { + loader := et.NewMemoryLoader(memData) + + scenarios := []struct { + view string + isError bool + }{ + { + view: "no-file.html", + isError: true, + }, + { + view: "file.html", + isError: false, + }, + } + + for _, s := range scenarios { + source, err := loader.Get(nil, s.view) + + if s.isError { + assert.Nil(t, source) + assert.Error(t, err) + } else if assert.NotNil(t, source) && assert.Nil(t, err) { + assert.Equal(t, s.view, source.Name) + assert.Equal(t, memData[s.view], source.Code) + assert.Empty(t, source.File) + } + } +} + +func TestMemoryLoader_IsFresh(t *testing.T) { + loader := et.NewMemoryLoader(memData) + + scenarios := []struct { + view string + t int64 + expected bool + isError bool + }{ + { + view: "no-file.html", + t: time.Now().Unix(), + expected: false, + isError: true, + }, + { + view: "file.html", + t: 0, + expected: true, + isError: false, + }, + { + view: "file.html", + t: time.Now().Unix(), + expected: true, + isError: false, + }, + } + + for _, s := range scenarios { + isFresh, err := loader.IsFresh(nil, s.view, s.t) + + assert.Equal(t, s.expected, isFresh) + if s.isError { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + } +} + +func TestMemoryLoader_Exists(t *testing.T) { + loader := et.NewMemoryLoader(memData) + + scenarios := []struct { + view string + expected bool + isError bool + }{ + { + view: "file.html", + expected: true, + isError: false, + }, + { + view: "no-file.html", + expected: false, + isError: true, + }, + } + + for _, s := range scenarios { + exists, err := loader.Exists(nil, s.view) + + assert.Equal(t, s.expected, exists) + if s.isError { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + } +} diff --git a/template_wrapper.go b/template_wrapper.go new file mode 100644 index 0000000..4bf949a --- /dev/null +++ b/template_wrapper.go @@ -0,0 +1,170 @@ +package et + +import ( + "bytes" + "context" + "golang.org/x/exp/slices" + "html/template" + "path" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" +) + +type TemplateWrapper struct { + HTML *template.Template + orig *template.Template + reExtends *regexp.Regexp + reTemplates *regexp.Regexp + names *sync.Map + parsed atomic.Bool + unix atomic.Int64 + loader Loader + global []string + ns string +} + +func NewTemplateWrapper( + html *template.Template, + loader Loader, + reExtends *regexp.Regexp, + reTemplates *regexp.Regexp, + global ...string, +) *TemplateWrapper { + t := &TemplateWrapper{ + HTML: html, + loader: loader, + reExtends: reExtends, + reTemplates: reTemplates, + global: global, + } + + if data := strings.SplitN(html.Name(), "/", 2); len(data) == 2 && '@' == data[0][0] { + t.ns = data[0] + "/" + } + + return t +} + +func (t *TemplateWrapper) IsFresh(ctx context.Context) (ok bool) { + if !t.parsed.Load() { + if err := t.Parse(ctx); err != nil { + return ok + } + } + + unix := t.unix.Load() + t.names.Range(func(key, _ any) bool { + ok, _ = t.loader.IsFresh(ctx, key.(string), unix) + return ok + }) + return +} + +func (t *TemplateWrapper) Parse(ctx context.Context) (err error) { + defer func() { + t.parsed.Store(true) + t.unix.Store(time.Now().Unix()) + }() + + if t.orig == nil { + if t.orig, err = t.HTML.Clone(); err != nil { + return + } + } else if t.HTML, err = t.orig.Clone(); err != nil { + return + } + + t.names = &sync.Map{} + + n := &node{} + if err = n.compile(ctx, t, t.HTML.Name()); err != nil { + return + } + n = n.selfParent() + + for i := len(t.global) - 1; i >= 0; i-- { + s := &node{} + if err = s.compile(ctx, t, t.global[i]); err != nil { + return + } + n.includes = slices.Insert(n.includes, 0, s) + } + + return n.parse(t.HTML) +} + +type node struct { + source *Source + parent *node + child *node + includes []*node +} + +func (n *node) selfParent() *node { + if n.parent == nil { + return n + } + return n.parent.selfParent() +} + +func (n *node) compile(ctx context.Context, w *TemplateWrapper, name string) (err error) { + if w.ns != "" && '@' == w.ns[0] && '@' != name[0] { + name = w.ns + name + } + + if n.source, err = w.loader.Get(ctx, name); err != nil { + return + } + + w.names.Store(name, struct{}{}) + + if extends := w.reExtends.FindAllSubmatch(n.source.Code, -1); len(extends) > 0 { + n.source.Code = w.reExtends.ReplaceAll(n.source.Code, []byte{}) + n.parent = &node{child: n} + if err = n.parent.compile(ctx, w, toString(extends[0][1])); err != nil { + return + } + } + + if includes := w.reTemplates.FindAllSubmatch(n.source.Code, -1); len(includes) > 0 { + for _, tpl := range includes { + include := &node{} + if err = include.compile(ctx, w, toString(tpl[1])); err != nil { + return + } + n.includes = append(n.includes, include) + if w.ns != "" && '@' == w.ns[0] && '@' != rune(tpl[1][0]) { + n.source.Code = bytes.Replace(n.source.Code, tpl[1], append(toBytes(w.ns), tpl[1]...), 1) + } + } + } + + return +} + +func (n *node) parse(t *template.Template) error { + if _, err := t.Parse(toString(n.source.Code)); err != nil { + return err + } + + for _, include := range n.includes { + if err := include.selfParent().parse(t.New(include.source.Name)); err != nil { + return err + } + } + + if n.child == nil { + return nil + } + + name := n.child.source.Name + if n.child.child == nil { + d, suffix := path.Split(name) + name = path.Join(d, "child_"+suffix) + } + + return n.child.parse(t.New(name)) +} diff --git a/template_wrapper_test.go b/template_wrapper_test.go new file mode 100644 index 0000000..f138769 --- /dev/null +++ b/template_wrapper_test.go @@ -0,0 +1,95 @@ +package et_test + +import ( + "bytes" + "context" + "fmt" + et "github.com/gowool/extends-template" + "github.com/stretchr/testify/assert" + "html/template" + "testing" + "time" +) + +const ( + htmlLayout = `{{block "content" .}}{{end}}` + htmlTitle = `

Title Test

` + htmlSubtitle = `

Subtitle Test

` + htmlView = `{{extends "layout.html"}}{{define "content"}}{{template "title.html"}}{{template "subtitle.html"}}{{end}}` + htmlResult = `

Title Test

Subtitle Test

` +) + +var htmlViews = map[string][]byte{ + "@main/layout.html": []byte(htmlLayout), + "@main/title.html": []byte(htmlTitle), + "@main/subtitle.html": []byte(htmlSubtitle), + "@main/view.html": []byte(htmlView), +} + +type wrapLoader struct { + t int64 +} + +func (l wrapLoader) Get(_ context.Context, name string) (*et.Source, error) { + if ok, _ := l.Exists(nil, name); ok { + return &et.Source{Name: name, Code: htmlViews[name]}, nil + } + return nil, fmt.Errorf("template %s not found", name) +} + +func (l wrapLoader) IsFresh(_ context.Context, name string, t int64) (bool, error) { + ok, _ := l.Exists(nil, name) + return ok && l.t < t, nil +} + +func (l wrapLoader) Exists(_ context.Context, name string) (bool, error) { + _, ok := htmlViews[name] + return ok, nil +} + +func TestTemplateWrapper_IsFresh(t *testing.T) { + scenarios := []struct { + t int64 + expected bool + }{ + { + t: time.Now().Add(24 * time.Hour).Unix(), + expected: false, + }, + { + t: time.Now().Add(-24 * time.Hour).Unix(), + expected: true, + }, + } + + for _, s := range scenarios { + name := "@main/view.html" + wrapper := et.NewTemplateWrapper( + template.New(name), + wrapLoader{t: s.t}, + et.ReExtends("{{", "}}"), + et.ReTemplate("{{", "}}")) + + isFresh := wrapper.IsFresh(nil) + + assert.Equal(t, s.expected, isFresh) + } +} + +func TestTemplateWrapper_Parse(t *testing.T) { + name := "@main/view.html" + wrapper := et.NewTemplateWrapper( + template.New(name), + wrapLoader{}, + et.ReExtends("{{", "}}"), + et.ReTemplate("{{", "}}")) + + for range []struct{}{{}, {}} { + if err := wrapper.Parse(nil); assert.NoError(t, err) && assert.NotNil(t, wrapper.HTML) { + var out bytes.Buffer + if err = wrapper.HTML.ExecuteTemplate(&out, name, nil); assert.NoError(t, err) { + assert.Equal(t, htmlResult, out.String()) + } + } + } +} diff --git a/tests/base/layout.html b/tests/base/layout.html new file mode 100644 index 0000000..22ae887 --- /dev/null +++ b/tests/base/layout.html @@ -0,0 +1,10 @@ + + + + + Test + + +{{block "content" .}}{{end}} + + \ No newline at end of file diff --git a/tests/base/views/home.html b/tests/base/views/home.html new file mode 100644 index 0000000..3e1fcbd --- /dev/null +++ b/tests/base/views/home.html @@ -0,0 +1,5 @@ +{{extends "layout.html"}} + +{{define "content"}} +

Home Test Page

+{{end}} \ No newline at end of file diff --git a/tests/main/views/home.html b/tests/main/views/home.html new file mode 100644 index 0000000..787f02a --- /dev/null +++ b/tests/main/views/home.html @@ -0,0 +1,5 @@ +{{extends "layout.html"}} + +{{define "content"}} +

Override Home Test Page

+{{end}} \ No newline at end of file