diff --git a/contracts/view/view.go b/contracts/view/view.go index d83bda7f3..ba904b98d 100644 --- a/contracts/view/view.go +++ b/contracts/view/view.go @@ -5,6 +5,11 @@ import "io/fs" type View interface { // Exists checks if a view with the specified name exists. Exists(view string) bool + // First returns a template for the first view in the list that can be rendered, which is + // not always a view that Exists: a file holding only define blocks under other names exists + // but cannot be rendered by its path. If none of them can be rendered, rendering the + // returned template fails. + First(views []string, data ...any) Template // LoadViewsFrom registers a package view directory for template fallback. // Templates from registered directories are loaded after app views; if a // template name is already defined by an app view or an earlier package, it is skipped. @@ -23,6 +28,9 @@ type View interface { // var views embed.FS // LoadViewsFromFS(fsys fs.FS, root string) + // Make returns a template for the view. The data may be a map with string keys or a struct; + // it is merged over the shared data when the template is rendered. + Make(view string, data ...any) Template // RegisteredViews returns the absolute paths of all registered package view directories. RegisteredViews() []string // RegisteredViewFS returns all package view filesystems registered with LoadViewsFromFS, @@ -38,3 +46,22 @@ type View interface { // GetShared returns a map containing all the shared data associated with the current view context. GetShared() map[string]any } + +// Template is a view bound to its data, ready to be rendered. A template collects data as it +// is built and is not safe for use from several goroutines at once; render it where it was +// built, or build one per goroutine. +type Template interface { + // Data returns a copy of the data passed to the template, without the shared data. + Data() map[string]any + // Name returns the name of the view. + Name() string + // Render renders the view and returns the resulting HTML. Templates are resolved the same + // way as for HTTP responses: application views first, then directories registered with + // LoadViewsFrom, then filesystems registered with LoadViewsFromFS. + // + // Rendering happens outside a request, so request-bound values such as csrf_token are + // not available. + Render() (string, error) + // With adds a key-value pair to the template data. + With(key string, value any) Template +} diff --git a/errors/list.go b/errors/list.go index 569fbe902..5395f4db9 100644 --- a/errors/list.go +++ b/errors/list.go @@ -373,6 +373,8 @@ var ( ViewFSRequired = New("view filesystem is required") ViewFSRootNotDirectory = New("view filesystem root %q is not a directory") + ViewInvalidData = New("view %s data must be a map with string keys or a struct, got %T") ViewInvalidFSRoot = New("invalid view filesystem root %q: %v") + ViewNoneExist = New("none of the views %v exist") ViewTemplateNotExist = New("view template %s does not exist") ) diff --git a/mocks/view/Template.go b/mocks/view/Template.go new file mode 100644 index 000000000..65211f3d4 --- /dev/null +++ b/mocks/view/Template.go @@ -0,0 +1,231 @@ +// Code generated by mockery. DO NOT EDIT. + +package view + +import ( + view "github.com/goravel/framework/contracts/view" + mock "github.com/stretchr/testify/mock" +) + +// Template is an autogenerated mock type for the Template type +type Template struct { + mock.Mock +} + +type Template_Expecter struct { + mock *mock.Mock +} + +func (_m *Template) EXPECT() *Template_Expecter { + return &Template_Expecter{mock: &_m.Mock} +} + +// Data provides a mock function with no fields +func (_m *Template) Data() map[string]interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Data") + } + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func() map[string]interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + return r0 +} + +// Template_Data_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Data' +type Template_Data_Call struct { + *mock.Call +} + +// Data is a helper method to define mock.On call +func (_e *Template_Expecter) Data() *Template_Data_Call { + return &Template_Data_Call{Call: _e.mock.On("Data")} +} + +func (_c *Template_Data_Call) Run(run func()) *Template_Data_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Template_Data_Call) Return(_a0 map[string]interface{}) *Template_Data_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Template_Data_Call) RunAndReturn(run func() map[string]interface{}) *Template_Data_Call { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with no fields +func (_m *Template) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Template_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type Template_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *Template_Expecter) Name() *Template_Name_Call { + return &Template_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *Template_Name_Call) Run(run func()) *Template_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Template_Name_Call) Return(_a0 string) *Template_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Template_Name_Call) RunAndReturn(run func() string) *Template_Name_Call { + _c.Call.Return(run) + return _c +} + +// Render provides a mock function with no fields +func (_m *Template) Render() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Render") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Template_Render_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Render' +type Template_Render_Call struct { + *mock.Call +} + +// Render is a helper method to define mock.On call +func (_e *Template_Expecter) Render() *Template_Render_Call { + return &Template_Render_Call{Call: _e.mock.On("Render")} +} + +func (_c *Template_Render_Call) Run(run func()) *Template_Render_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Template_Render_Call) Return(_a0 string, _a1 error) *Template_Render_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Template_Render_Call) RunAndReturn(run func() (string, error)) *Template_Render_Call { + _c.Call.Return(run) + return _c +} + +// With provides a mock function with given fields: key, value +func (_m *Template) With(key string, value interface{}) view.Template { + ret := _m.Called(key, value) + + if len(ret) == 0 { + panic("no return value specified for With") + } + + var r0 view.Template + if rf, ok := ret.Get(0).(func(string, interface{}) view.Template); ok { + r0 = rf(key, value) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(view.Template) + } + } + + return r0 +} + +// Template_With_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'With' +type Template_With_Call struct { + *mock.Call +} + +// With is a helper method to define mock.On call +// - key string +// - value interface{} +func (_e *Template_Expecter) With(key interface{}, value interface{}) *Template_With_Call { + return &Template_With_Call{Call: _e.mock.On("With", key, value)} +} + +func (_c *Template_With_Call) Run(run func(key string, value interface{})) *Template_With_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(interface{})) + }) + return _c +} + +func (_c *Template_With_Call) Return(_a0 view.Template) *Template_With_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Template_With_Call) RunAndReturn(run func(string, interface{}) view.Template) *Template_With_Call { + _c.Call.Return(run) + return _c +} + +// NewTemplate creates a new instance of Template. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTemplate(t interface { + mock.TestingT + Cleanup(func()) +}) *Template { + mock := &Template{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/view/View.go b/mocks/view/View.go index b2e3bc6c3..3c8c74300 100644 --- a/mocks/view/View.go +++ b/mocks/view/View.go @@ -5,6 +5,7 @@ package view import ( fs "io/fs" + view "github.com/goravel/framework/contracts/view" mock "github.com/stretchr/testify/mock" ) @@ -67,6 +68,65 @@ func (_c *View_Exists_Call) RunAndReturn(run func(string) bool) *View_Exists_Cal return _c } +// First provides a mock function with given fields: views, data +func (_m *View) First(views []string, data ...interface{}) view.Template { + var _ca []interface{} + _ca = append(_ca, views) + _ca = append(_ca, data...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for First") + } + + var r0 view.Template + if rf, ok := ret.Get(0).(func([]string, ...interface{}) view.Template); ok { + r0 = rf(views, data...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(view.Template) + } + } + + return r0 +} + +// View_First_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'First' +type View_First_Call struct { + *mock.Call +} + +// First is a helper method to define mock.On call +// - views []string +// - data ...interface{} +func (_e *View_Expecter) First(views interface{}, data ...interface{}) *View_First_Call { + return &View_First_Call{Call: _e.mock.On("First", + append([]interface{}{views}, data...)...)} +} + +func (_c *View_First_Call) Run(run func(views []string, data ...interface{})) *View_First_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].([]string), variadicArgs...) + }) + return _c +} + +func (_c *View_First_Call) Return(_a0 view.Template) *View_First_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *View_First_Call) RunAndReturn(run func([]string, ...interface{}) view.Template) *View_First_Call { + _c.Call.Return(run) + return _c +} + // GetShared provides a mock function with no fields func (_m *View) GetShared() map[string]interface{} { ret := _m.Called() @@ -181,6 +241,65 @@ func (_c *View_LoadViewsFromFS_Call) RunAndReturn(run func(fs.FS, string)) *View return _c } +// Make provides a mock function with given fields: _a0, data +func (_m *View) Make(_a0 string, data ...interface{}) view.Template { + var _ca []interface{} + _ca = append(_ca, _a0) + _ca = append(_ca, data...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Make") + } + + var r0 view.Template + if rf, ok := ret.Get(0).(func(string, ...interface{}) view.Template); ok { + r0 = rf(_a0, data...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(view.Template) + } + } + + return r0 +} + +// View_Make_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Make' +type View_Make_Call struct { + *mock.Call +} + +// Make is a helper method to define mock.On call +// - _a0 string +// - data ...interface{} +func (_e *View_Expecter) Make(_a0 interface{}, data ...interface{}) *View_Make_Call { + return &View_Make_Call{Call: _e.mock.On("Make", + append([]interface{}{_a0}, data...)...)} +} + +func (_c *View_Make_Call) Run(run func(_a0 string, data ...interface{})) *View_Make_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *View_Make_Call) Return(_a0 view.Template) *View_Make_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *View_Make_Call) RunAndReturn(run func(string, ...interface{}) view.Template) *View_Make_Call { + _c.Call.Return(run) + return _c +} + // RegisteredViewFS provides a mock function with no fields func (_m *View) RegisteredViewFS() []fs.FS { ret := _m.Called() diff --git a/view/render.go b/view/render.go new file mode 100644 index 000000000..f6b9ada1e --- /dev/null +++ b/view/render.go @@ -0,0 +1,246 @@ +package view + +import ( + "html/template" + "io/fs" + "os" + "path" + "strings" + "sync/atomic" + "text/template/parse" + + "github.com/goravel/framework/errors" + "github.com/goravel/framework/packages/paths" + "github.com/goravel/framework/support" +) + +// bodyHolder is the name a file's text is parsed under while it is taken apart. Parsing it +// under the file's own path would merge the text around the define blocks with a define block +// carrying that same path, which is the convention these views follow. +const bodyHolder = "\x00body" + +// compiled is the result of parsing every view source, including a failure, so a broken +// view is not parsed again on every render. +type compiled struct { + tmpl *template.Template + err error + // sizes is keyed by the names a view can be rendered by and holds the last output size of + // each, used to size the next buffer. Those names are not every name html/template ends up + // associating with the set: a file that only holds define blocks also leaves an empty + // template behind, and every set carries an unnamed root. Rendering either returns an empty + // string, which must not pass for a view. + sizes map[string]*atomic.Int64 +} + +// render executes the named template with the given values. +func (r *View) render(name string, values map[string]any) (string, error) { + c := r.compile() + if c.err != nil { + return "", c.err + } + + size, ok := c.sizes[name] + if !ok { + return "", errors.ViewTemplateNotExist.Args(name) + } + + var buf strings.Builder + buf.Grow(int(size.Load())) + if err := c.tmpl.ExecuteTemplate(&buf, name, values); err != nil { + return "", err + } + size.Store(int64(buf.Len())) + + return buf.String(), nil +} + +// renderable reports whether the view resolves to a template that can be rendered, which is +// the question First has to ask: a file can exist without being addressable under its own +// name, for example when it holds nothing but define blocks. When the sources cannot be +// parsed there is no telling which names resolve, so every view counts as renderable and +// Render reports the parse error rather than First reporting that none of the views exist. +func (r *View) renderable(view string) bool { + c := r.compile() + if c.err != nil { + return true + } + + _, ok := c.sizes[view] + + return ok +} + +// compile returns the parsed view sources, parsing them on first use. The result is kept +// until a new source is registered. +func (r *View) compile() *compiled { + if c := r.compiled.Load(); c != nil { + return c + } + + r.compileMu.Lock() + defer r.compileMu.Unlock() + + if c := r.compiled.Load(); c != nil { + return c + } + + c := &compiled{sizes: make(map[string]*atomic.Int64)} + var names []string + c.tmpl, names, c.err = r.parse() + for _, name := range names { + c.sizes[name] = &atomic.Int64{} + } + r.compiled.Store(c) + + return c +} + +func (r *View) resetCompiled() { + r.compileMu.Lock() + defer r.compileMu.Unlock() + + r.compiled.Store(nil) +} + +// sources returns every place templates are loaded from, in precedence order: the +// application's resources/views, then directories registered via LoadViewsFrom, then +// filesystems registered via LoadViewsFromFS. The first source to contribute a name owns it, +// so a package can never replace a view the application, or a package registered before it, +// already provides. +func (r *View) sources() []fs.FS { + var sources []fs.FS + if dir := paths.Abs(support.Config.Paths.Resources, "views"); isDir(dir) { + sources = append(sources, os.DirFS(dir)) + } + for _, dir := range r.RegisteredViews() { + if isDir(dir) { + sources = append(sources, os.DirFS(dir)) + } + } + + return append(sources, r.RegisteredViewFS()...) +} + +// parse loads every source into one template set and returns the names the set can be +// rendered by. A file contributes each of its non-empty define blocks, and, when it has text +// of its own around them, its path within the source plus, for a nested file, its base name, +// which is what html/template.ParseFS and the route drivers address it by. Names are owned per +// source: views in one directory override each other the way html/template behaves on its +// own, while a later source is only used for the names no earlier source contributed. It +// returns a nil template when no source contributes one. +func (r *View) parse() (*template.Template, []string, error) { + tmpl := template.New("") + owned := make(map[string]bool) + renderable := make(map[string]bool) + var names []string + loaded := false + + for _, source := range r.sources() { + contributed := make(map[string]bool) + + // claim moves one template into the set unless an earlier source owns the name. An alias + // only ever fills a free name: it is a second way to address a file, never a view in its + // own right, so it must not displace one. + claim := func(name string, tree *parse.Tree, alias bool) error { + if owned[name] || (alias && contributed[name]) { + return nil + } + + // AddParseTree keeps an existing body rather than letting an empty one replace it, so + // a layout's {{ block }} placeholder cannot wipe the page that fills it, whichever of + // the two is walked first. + tree.Name = name + if _, err := tmpl.AddParseTree(name, tree); err != nil { + return err + } + contributed[name] = true + + // An empty tree stays in the set, so {{ template }} and {{ block }} references to it + // resolve, but it is not a view: rendering it returns an empty string, which must + // not pass for a page. The name becomes renderable once a body for it turns up, + // whichever of the two is walked first. + if !renderable[name] && !parse.IsEmptyTree(tree.Root) { + renderable[name] = true + names = append(names, name) + } + + return nil + } + + err := fs.WalkDir(source, ".", func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + content, err := fs.ReadFile(source, name) + if err != nil { + return err + } + text := string(content) + + // The file is parsed on its own and its templates are moved into the set one at a + // time. Parsing it straight into the set would apply all of its define blocks at once, + // letting a package take a name it does not own because another name in the same file + // happened to be free. + file, err := template.New(bodyHolder).Parse(text) + if err != nil { + return parseError(name, text, err) + } + loaded = true + + for _, sub := range file.Templates() { + if sub.Name() == bodyHolder || sub.Tree == nil { + continue + } + // Report positions against the file rather than the name it was parsed under. + sub.Tree.ParseName = name + if err := claim(sub.Name(), sub.Tree, false); err != nil { + return err + } + } + + body := file.Lookup(bodyHolder) + if body == nil || body.Tree == nil || parse.IsEmptyTree(body.Tree.Root) { + return nil + } + body.Tree.ParseName = name + + if err := claim(name, body.Tree, false); err != nil { + return err + } + if base := path.Base(name); base != name { + return claim(base, body.Tree.Copy(), true) + } + + return nil + }) + if err != nil { + return nil, nil, err + } + + for name := range contributed { + owned[name] = true + } + } + + if !loaded { + return nil, nil, nil + } + + return tmpl, names, nil +} + +// parseError reports a parse failure under the file's path, which is not the name the text was +// parsed under and so not the name the failure would otherwise carry. +func parseError(name, text string, err error) error { + if _, named := template.New(name).Parse(text); named != nil { + return named + } + + return err +} + +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} diff --git a/view/template.go b/view/template.go new file mode 100644 index 000000000..893737736 --- /dev/null +++ b/view/template.go @@ -0,0 +1,147 @@ +package view + +import ( + "reflect" + + contractsview "github.com/goravel/framework/contracts/view" + "github.com/goravel/framework/errors" +) + +var _ contractsview.Template = (*Template)(nil) + +// Template is a view bound to its data. Data that cannot be rendered, and a First that matched +// nothing, are reported by Render rather than when the template is built. +type Template struct { + view *View + name string + data map[string]any + err error +} + +// NewTemplate binds a view to its data. The data may be a map with string keys or a struct. +// It is copied at the top level only: replacing a key in the caller's map is not picked up, +// but nested maps, slices and pointers are still shared with the caller and must not be +// changed while the template is in use. +func NewTemplate(view *View, name string, data ...any) *Template { + values, err := toMap(name, data...) + + return &Template{ + view: view, + name: name, + data: values, + err: err, + } +} + +func (r *Template) Data() map[string]any { + data := make(map[string]any, len(r.data)) + for key, value := range r.data { + data[key] = value + } + + return data +} + +func (r *Template) Name() string { + return r.name +} + +func (r *Template) Render() (string, error) { + if r.err != nil { + return "", r.err + } + + values := make(map[string]any, len(r.data)+8) + r.view.shared.Range(func(key, value any) bool { + values[key.(string)] = value + return true + }) + for key, value := range r.data { + values[key] = value + } + + return r.view.render(r.name, values) +} + +func (r *Template) With(key string, value any) contractsview.Template { + r.data[key] = value + + return r +} + +// toMap copies the top level of the given map or struct into a new map, leaving the caller's +// map untouched. +func toMap(view string, data ...any) (map[string]any, error) { + if len(data) == 0 || data[0] == nil { + return make(map[string]any), nil + } + + // The map the templates already want is copied directly: reflect boxes every value it + // reads back, which for this type costs an allocation per entry and buys nothing. + switch typed := data[0].(type) { + case map[string]any: + values := make(map[string]any, len(typed)) + for key, value := range typed { + values[key] = value + } + + return values, nil + case *map[string]any: + if typed == nil { + return make(map[string]any), nil + } + + values := make(map[string]any, len(*typed)) + for key, value := range *typed { + values[key] = value + } + + return values, nil + } + + value := reflect.ValueOf(data[0]) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return make(map[string]any), nil + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return make(map[string]any), errors.ViewInvalidData.Args(view, data[0]) + } + values := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + values[iter.Key().String()] = iter.Value().Interface() + } + + return values, nil + case reflect.Struct: + values := make(map[string]any, value.NumField()) + for i := 0; i < value.NumField(); i++ { + field := value.Type().Field(i) + if !field.IsExported() { + continue + } + // Pointer fields are dereferenced the same way the route drivers do it, so a + // nil pointer renders as an empty value instead of "". + fieldValue := value.Field(i) + if fieldValue.Kind() == reflect.Pointer { + if fieldValue.IsNil() { + values[field.Name] = nil + } else { + values[field.Name] = fieldValue.Elem().Interface() + } + } else { + values[field.Name] = fieldValue.Interface() + } + } + + return values, nil + default: + return make(map[string]any), errors.ViewInvalidData.Args(view, data[0]) + } +} diff --git a/view/template_test.go b/view/template_test.go new file mode 100644 index 000000000..6961378e9 --- /dev/null +++ b/view/template_test.go @@ -0,0 +1,550 @@ +package view + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +func TestTemplateRender(t *testing.T) { + setupAppViews(t, map[string]string{ + "foo.tmpl": `{{ define "foo.tmpl" }}app foo{{ end }}`, + "greeting.tmpl": `{{ define "greeting.tmpl" }}{{ .Greeting }}, {{ .Name }}{{ end }}`, + "raw.tmpl": `raw {{ .Name }}`, + }) + + pkgDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "shared.tmpl"), []byte(`{{ define "shared.tmpl" }}from dir{{ end }}`), 0o644)) + + pkgB := fstest.MapFS{ + "views/bar.tmpl": {Data: []byte(`{{ define "bar.tmpl" }}package bar{{ end }}`)}, + "views/shared.tmpl": {Data: []byte(`{{ define "shared.tmpl" }}from embedded{{ end }}`)}, + } + + view := NewView() + view.Share("Greeting", "Hello") + view.Share("Name", "shared") + view.LoadViewsFrom(pkgDir) + view.LoadViewsFromFS(testViews, "testdata/views") + view.LoadViewsFromFS(pkgB, "views") + + type page struct { + Title string + Nav string + private string + } + + tests := []struct { + name string + view string + data []any + expect string + }{ + {name: "application overrides embedded package", view: "foo.tmpl", expect: "app foo"}, + {name: "filesystem package overrides embedded package", view: "shared.tmpl", expect: "from dir"}, + {name: "embedded package fallback", view: "bar.tmpl", expect: "package bar"}, + {name: "file without define block", view: "raw.tmpl", data: []any{map[string]any{"Name": "Goravel"}}, expect: "raw Goravel"}, + {name: "shared data only", view: "greeting.tmpl", expect: "Hello, shared"}, + {name: "map overrides shared data", view: "greeting.tmpl", data: []any{map[string]string{"Name": "Goravel"}}, expect: "Hello, Goravel"}, + {name: "nil data", view: "greeting.tmpl", data: []any{nil}, expect: "Hello, shared"}, + {name: "nil pointer data", view: "greeting.tmpl", data: []any{(*page)(nil)}, expect: "Hello, shared"}, + { + name: "struct data with nested layout, partial and block", + view: "pages/home.tmpl", + data: []any{page{Title: "Home", Nav: "Menu", private: "hidden"}}, + expect: "

Home

", + }, + { + name: "pointer to struct", + view: "pages/home.tmpl", + data: []any{&page{Title: "Home", Nav: "Menu"}}, + expect: "

Home

", + }, + { + name: "values are HTML escaped", + view: "greeting.tmpl", + data: []any{map[string]any{"Name": "