From 23a5c155cf4278b3cf6dfa0d01aaff095ee87364 Mon Sep 17 00:00:00 2001 From: darakanoit Date: Sat, 19 Sep 2026 16:20:51 +0500 Subject: [PATCH 1/5] feat: [#773] add View.Make to render a template to a string Rendering now lives in the view module, so a view can be rendered outside a request, for example to warm a page cache from a command or a queued job. The API follows Laravel's split between the view factory and a view instance: View.Make and View.First return a Template, and Template.Render returns the HTML. Template also has With, Name and Data. Templates are resolved the same way the gin and fiber drivers do it: resources/views, then LoadViewsFrom directories, then LoadViewsFromFS filesystems, first definition wins. The parsed set is cached and reset when a new view source is registered. --- contracts/view/view.go | 22 ++++ errors/list.go | 2 + mocks/view/Template.go | 231 +++++++++++++++++++++++++++++++++++++++++ mocks/view/View.go | 119 +++++++++++++++++++++ view/render.go | 126 ++++++++++++++++++++++ view/template.go | 91 ++++++++++++++++ view/template_test.go | 217 ++++++++++++++++++++++++++++++++++++++ view/view.go | 30 +++++- view/view_test.go | 3 - 9 files changed, 836 insertions(+), 5 deletions(-) create mode 100644 mocks/view/Template.go create mode 100644 view/render.go create mode 100644 view/template.go create mode 100644 view/template_test.go diff --git a/contracts/view/view.go b/contracts/view/view.go index d83bda7f3..7a572a9b2 100644 --- a/contracts/view/view.go +++ b/contracts/view/view.go @@ -5,6 +5,9 @@ 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 exists. If none of them + // exist, 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. @@ -29,6 +32,9 @@ type View interface { // in registration order. Each returned filesystem is already rooted at the root passed to // LoadViewsFromFS, so template paths are relative to it (for example "layouts/app.tmpl"). RegisteredViewFS() []fs.FS + // Make returns a template for the view. The data may be a map or a struct; it is merged + // over the shared data when the template is rendered. + Make(view string, data ...any) Template // Share associates a key-value pair, where the key is a string and the value is of any type, // with the current view context. This shared data can be accessed by other parts of the application. Share(key string, value any) @@ -38,3 +44,19 @@ type View interface { // GetShared returns a map containing all the shared data associated with the current view context. GetShared() map[string]any } + +type Template interface { + // Data returns 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..e511c6cd3 100644 --- a/errors/list.go +++ b/errors/list.go @@ -375,4 +375,6 @@ var ( ViewFSRootNotDirectory = New("view filesystem root %q is not a directory") ViewInvalidFSRoot = New("invalid view filesystem root %q: %v") ViewTemplateNotExist = New("view template %s does not exist") + ViewInvalidData = New("view %s data must be a map or struct, got %T") + ViewNoneExist = New("none of the views %v 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..a700b9d66 --- /dev/null +++ b/view/render.go @@ -0,0 +1,126 @@ +package view + +import ( + "bytes" + "html/template" + "io/fs" + "os" + stdpath "path" + "regexp" + + "github.com/goravel/framework/errors" + "github.com/goravel/framework/packages/paths" + "github.com/goravel/framework/support" +) + +var defineRe = regexp.MustCompile(`\{\{\s*define\s+"([^"]+)"`) + +// render executes the named template with the given values. +func (r *View) render(name string, values map[string]any) (string, error) { + tmpl, err := r.templates() + if err != nil { + return "", err + } + if tmpl == nil || tmpl.Lookup(name) == nil { + return "", errors.ViewTemplateNotExist.Args(name) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, name, values); err != nil { + return "", err + } + + return buf.String(), nil +} + +// templates returns the compiled template set, parsing every view source on first use. +// The set is reset whenever a new source is registered. +func (r *View) templates() (*template.Template, error) { + r.tmplMu.Lock() + defer r.tmplMu.Unlock() + + if r.tmpl != nil { + return r.tmpl, nil + } + + tmpl, err := r.parse() + if err != nil { + return nil, err + } + r.tmpl = tmpl + + return tmpl, nil +} + +func (r *View) resetTemplate() { + r.tmplMu.Lock() + defer r.tmplMu.Unlock() + + r.tmpl = nil +} + +// parse loads every source into one template set in precedence order: the application's +// resources/views, then directories registered via LoadViewsFrom, then filesystems registered +// via LoadViewsFromFS. The first source to define a template name wins. It returns nil when +// no source contributes a template. +func (r *View) parse() (*template.Template, error) { + 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)) + } + } + sources = append(sources, r.RegisteredViewFS()...) + + tmpl := template.New("") + defined := make(map[string]bool) + loaded := false + + for _, source := range sources { + 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) + + // A file without a define block is addressed by its base name, the name it is parsed as. + templateName := stdpath.Base(name) + if matches := defineRe.FindStringSubmatch(text); len(matches) > 1 { + templateName = matches[1] + } + if defined[templateName] { + return nil + } + defined[templateName] = true + + if _, err := tmpl.New(stdpath.Base(name)).Parse(text); err != nil { + return err + } + loaded = true + + return nil + }) + if err != nil { + return nil, err + } + } + + if !loaded { + return nil, nil + } + + return tmpl, nil +} + +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..bcf3b69d2 --- /dev/null +++ b/view/template.go @@ -0,0 +1,91 @@ +package view + +import ( + "reflect" + + contractsview "github.com/goravel/framework/contracts/view" + "github.com/goravel/framework/errors" +) + +type Template struct { + view *View + name string + data map[string]any + err error +} + +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 { + return r.data +} + +func (r *Template) Name() string { + return r.name +} + +func (r *Template) Render() (string, error) { + if r.err != nil { + return "", r.err + } + + values := r.view.GetShared() + 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 given map or struct into a new map, leaving the caller's data untouched. +func toMap(view string, data ...any) (map[string]any, error) { + values := make(map[string]any) + if len(data) == 0 || data[0] == nil { + return values, nil + } + + value := reflect.ValueOf(data[0]) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return values, nil + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return values, errors.ViewInvalidData.Args(view, data[0]) + } + iter := value.MapRange() + for iter.Next() { + values[iter.Key().String()] = iter.Value().Interface() + } + case reflect.Struct: + for i := 0; i < value.NumField(); i++ { + field := value.Type().Field(i) + if field.IsExported() { + values[field.Name] = value.Field(i).Interface() + } + } + default: + return values, errors.ViewInvalidData.Args(view, data[0]) + } + + return values, nil +} diff --git a/view/template_test.go b/view/template_test.go new file mode 100644 index 000000000..8378dc16c --- /dev/null +++ b/view/template_test.go @@ -0,0 +1,217 @@ +package view + +import ( + "os" + "path/filepath" + "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: "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": "