diff --git a/README.md b/README.md index a5dc6e5..d22329e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ * [HTMX support](#htmx-support) * [Rendering the page](#rendering-the-page) * [Template renderer](#template-renderer) + * [Custom functions](#custom-functions) * [Caching](#caching) * [Hot-reload for development](#hot-reload-for-development) * [File configuration](#file-configuration) @@ -759,7 +760,7 @@ Many examples of its usage are available in the included examples: - All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience. - All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX. - The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI. -- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts vi AJAX. +- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX. All of this can be easily accomplished without writing any JavaScript at all. @@ -814,35 +815,64 @@ func (c *Home) Get(ctx echo.Context) error { The _template renderer_ is a _Service_ on the `Container` that aims to make template parsing and rendering easy and flexible. It is the mechanism that allows the `Page` to do [automatic template parsing](#automatic-template-parsing). The standard `html/template` is still the engine used behind the scenes. The code can be found in `services/template_renderer.go`. -While there are several methods available, the following is the primary one used: +Here is an example of a complex rendering that uses multiple template files as well as an entire directory of template files: -`ParseAndExecute(cacheGroup, cacheID, baseName string, files []string, directories []string, data interface{})` +```go +buf, err = c.TemplateRenderer. + Parse(). + Group("page"). + Key("home"). + Base("main"). + Files("layouts/main", "pages/home"). + Directories("components"). + Execute(data) +``` + +This will do the following: +- [Cache](#caching) the parsed template with a _group_ of `page` and _key_ of `home` so this parse only happens once +- Set the _base template file_ as `main` +- Include the templates `templates/layout/main.gohtml` and `templates/pages/home.gohtml` +- Include all templates located within the directory `templates/components` +- Include the [funcmap](#funcmap) +- Execute the parsed template with `data` being passed in to the templates Using the example from the [page rendering](#rendering-the-page), this is what the `Controller` will execute: ```go -buf, err = c.TemplateRenderer.ParseAndExecute( - "page", - page.Name, - page.Layout, - []string{ +buf, err = c.Container.TemplateRenderer. + Parse(). + Group("page"). + Key(page.Name). + Base(page.Layout). + Files( fmt.Sprintf("layouts/%s", page.Layout), fmt.Sprintf("pages/%s", page.Name), - }, - []string{"components"}, - page, -) + ). + Directories("components"). + Execute(page) ``` -The parameters represent: - - `cacheGroup`: The _group_ to cache the parsed templates in - - `cacheID`: The _ID_ of the cache within the _group_ - - `baseName`: The name of the base template, excluding the extension - - `files`: A list of individual template files to include, excluding the extension and template directory - - `directories`: A list of directories to include all templates contained - - `data`: The data object to send to the templates +If you have a need to _separately_ parse and cache the templates then later execute, you can separate the operations: -All templates will be parsed with the [funcap](#funcmap). +```go +_, err := c.TemplateRenderer. + Parse(). + Group("my-group"). + Key("my-key"). + Base("auth"). + Files("layouts/auth", "pages/login"). + Directories("components"). + Store() +``` + +```go +tpl, err := c.TemplateRenderer.Load("my-group", "my-key") +buf, err := tpl.Execute(data) +``` + +### Custom functions + +All templates will be parsed with the [funcmap](#funcmap) so all of your custom functions as well as the functions provided by [sprig](https://github.com/Masterminds/sprig) will be available. ### Caching @@ -935,7 +965,7 @@ err := c.Cache. Flush(). Group("my-group"). Key("my-key"). - Exec(ctx) + Execute(ctx) ``` ### Flush tags @@ -946,7 +976,7 @@ This will flush all cache entries that were tagged with the given tags. err := c.Cache. Flush(). Tags("tag1", "tag2"). - Exec(ctx) + Execute(ctx) ``` ## Static files @@ -979,7 +1009,7 @@ Where `9fhe73kaf3` is the randomly-generated cache-buster. An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is because there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications. -The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `mail.Send`. +The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`. The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address. diff --git a/controller/controller.go b/controller/controller.go index 577f887..4104c29 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -52,17 +52,17 @@ func (c *Controller) RenderPage(ctx echo.Context, page Page) error { // 2. The content template specified in Page.Name // 3. All templates within the components directory // Also included is the function map provided by the funcmap package - buf, err = c.Container.TemplateRenderer.ParseAndExecute( - "page:htmx", - page.Name, - "htmx", - []string{ + buf, err = c.Container.TemplateRenderer. + Parse(). + Group("page:htmx"). + Key(page.Name). + Base("htmx"). + Files( "htmx", fmt.Sprintf("pages/%s", page.Name), - }, - []string{"components"}, - page, - ) + ). + Directories("components"). + Execute(page) } else { // Parse and execute the templates for the Page // As mentioned in the documentation for the Page struct, the templates used for the page will be: @@ -70,17 +70,17 @@ func (c *Controller) RenderPage(ctx echo.Context, page Page) error { // 2. The content template specified in Page.Name // 3. All templates within the components directory // Also included is the function map provided by the funcmap package - buf, err = c.Container.TemplateRenderer.ParseAndExecute( - "page", - page.Name, - page.Layout, - []string{ + buf, err = c.Container.TemplateRenderer. + Parse(). + Group("page"). + Key(page.Name). + Base(page.Layout). + Files( fmt.Sprintf("layouts/%s", page.Layout), fmt.Sprintf("pages/%s", page.Name), - }, - []string{"components"}, - page, - ) + ). + Directories("components"). + Execute(page) } if err != nil { diff --git a/controller/controller_test.go b/controller/controller_test.go index 9248fe8..6d313b5 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -100,7 +100,7 @@ func TestController_RenderPage(t *testing.T) { expectedTemplates[f.Name()] = true } - for _, v := range parsed.Templates() { + for _, v := range parsed.Template.Templates() { delete(expectedTemplates, v.Name()) } assert.Empty(t, expectedTemplates) @@ -133,7 +133,7 @@ func TestController_RenderPage(t *testing.T) { expectedTemplates[f.Name()] = true } - for _, v := range parsed.Templates() { + for _, v := range parsed.Template.Templates() { delete(expectedTemplates, v.Name()) } assert.Empty(t, expectedTemplates) @@ -167,7 +167,7 @@ func TestController_RenderPage(t *testing.T) { err = c.Cache. Flush(). Tags(p.Cache.Tags[0]). - Exec(context.Background()) + Execute(context.Background()) require.NoError(t, err) // Refetch from the cache and expect no results diff --git a/services/cache.go b/services/cache.go index 4e3f514..bca049a 100644 --- a/services/cache.go +++ b/services/cache.go @@ -195,8 +195,8 @@ func (c *cacheFlush) Tags(tags ...string) *cacheFlush { return c } -// Exec flushes the data from the cache -func (c *cacheFlush) Exec(ctx context.Context) error { +// Execute flushes the data from the cache +func (c *cacheFlush) Execute(ctx context.Context) error { if len(c.tags) > 0 { if err := c.client.cache.Invalidate(ctx, store.InvalidateOptions{ Tags: c.tags, diff --git a/services/cache_test.go b/services/cache_test.go index 41003fd..2934763 100644 --- a/services/cache_test.go +++ b/services/cache_test.go @@ -51,7 +51,7 @@ func TestCacheClient(t *testing.T) { Flush(). Group(group). Key(key). - Exec(context.Background()) + Execute(context.Background()) require.NoError(t, err) // The data should be gone @@ -81,7 +81,7 @@ func TestCacheClient(t *testing.T) { err = c.Cache. Flush(). Tags("tag1"). - Exec(context.Background()) + Execute(context.Background()) require.NoError(t, err) // The data should be gone diff --git a/services/mail.go b/services/mail.go index 7f59b78..62e481a 100644 --- a/services/mail.go +++ b/services/mail.go @@ -1,6 +1,7 @@ package services import ( + "errors" "fmt" "github.com/mikestefanello/pagoda/config" @@ -21,6 +22,7 @@ type ( templates *TemplateRenderer } + // mail represents an email to be sent mail struct { client *MailClient from string @@ -53,6 +55,43 @@ func (m *MailClient) skipSend() bool { return m.config.App.Environment != config.EnvProduction } +// send attempts to send the email +func (m *MailClient) send(email *mail, ctx echo.Context) error { + switch { + case email.to == "": + return errors.New("email cannot be sent without a to address") + case email.body == "" && email.template == "": + return errors.New("email cannot be sent without a body or template") + } + + // Check if a template was supplied + if email.template != "" { + // Parse and execute template + buf, err := m.templates. + Parse(). + Group("mail"). + Key(email.template). + Base(email.template). + Files(fmt.Sprintf("emails/%s", email.template)). + Execute(email.templateData) + + if err != nil { + return err + } + + email.body = buf.String() + } + + // Check if mail sending should be skipped + if m.skipSend() { + ctx.Logger().Debugf("skipping email sent to: %s", email.to) + return nil + } + + // TODO: Finish based on your mail sender of choice! + return nil +} + // From sets the email from address func (m *mail) From(from string) *mail { m.from = from @@ -96,30 +135,5 @@ func (m *mail) TemplateData(data interface{}) *mail { // Send attempts to send the email func (m *mail) Send(ctx echo.Context) error { - // Check if a template was supplied - if m.template != "" { - // Parse and execute template - buf, err := m.client.templates.ParseAndExecute( - "mail", - m.template, - m.template, - []string{fmt.Sprintf("emails/%s", m.template)}, - []string{}, - m.templateData, - ) - if err != nil { - return err - } - - m.body = buf.String() - } - - // Check if mail sending should be skipped - if m.client.skipSend() { - ctx.Logger().Debugf("skipping email sent to: %s", m.to) - return nil - } - - // TODO: Finish based on your mail sender of choice! - return nil + return m.client.send(m, ctx) } diff --git a/services/template_renderer.go b/services/template_renderer.go index fc33270..a878cfd 100644 --- a/services/template_renderer.go +++ b/services/template_renderer.go @@ -14,21 +14,47 @@ import ( "github.com/mikestefanello/pagoda/funcmap" ) -// TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of -// templates while also providing caching and/or hot-reloading depending on your current environment -type TemplateRenderer struct { - // templateCache stores a cache of parsed page templates - templateCache sync.Map +type ( + // TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of + // templates while also providing caching and/or hot-reloading depending on your current environment + TemplateRenderer struct { + // templateCache stores a cache of parsed page templates + templateCache sync.Map - // funcMap stores the template function map - funcMap template.FuncMap + // funcMap stores the template function map + funcMap template.FuncMap - // templatePath stores the complete path to the templates directory - templatesPath string + // templatePath stores the complete path to the templates directory + templatesPath string - // config stores application configuration - config *config.Config -} + // config stores application configuration + config *config.Config + } + + // TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache + TemplateParsed struct { + // Template is the parsed template + Template *template.Template + + // build stores the build data used to parse the template + build *templateBuild + } + + // templateBuild stores the build data used to parse a template + templateBuild struct { + group string + key string + base string + files []string + directories []string + } + + // templateBuilder handles chaining a template parse operation + templateBuilder struct { + build *templateBuild + renderer *TemplateRenderer + } +) // NewTemplateRenderer creates a new TemplateRenderer func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer { @@ -47,123 +73,12 @@ func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer { return t } -// Parse parses a set of templates and caches them for quick execution -// If the application environment is set to local, the cache will be bypassed and templates will be -// parsed upon each request so hot-reloading is possible without restarts. -// -// All template files and template directories must be provided relative to the templates directory -// and without template extensions. Those two values can be altered via the config package. -// -// cacheGroup is used to separate templates in to groups within the cache to avoid potential conflicts -// with the cacheID. -// -// baseName is the filename of the base template without any paths or an extension. -// files is a slice of all individual template files that will be included in the parse. -// directories is a slice of directories which all template files witin them will be included in the parse -// -// Also included will be the function map provided by the funcmap package. -// -// An example usage of this: -// t.Parse( -// "page", -// "home", -// "main", -// []string{ -// "layouts/main", -// "pages/home", -// }, -// []string{"components"}, -//) -// -// This will perform a template parse which will: -// - Be cached using a key of "page:home" -// - Include the layouts/main.gohtml and pages/home.gohtml templates -// - Include all templates within the components directory -// - Include the function map within the funcmap package -// - Set the base template as main.gohtml -func (t *TemplateRenderer) Parse(cacheGroup, cacheID, baseName string, files []string, directories []string) error { - cacheKey := t.getCacheKey(cacheGroup, cacheID) - - // Check if the template has not yet been parsed or if the app environment is local, so that - // templates reflect changes without having the restart the server - if _, err := t.Load(cacheGroup, cacheID); err != nil || t.config.App.Environment == config.EnvLocal { - // Initialize the parsed template with the function map - parsed := template.New(baseName + config.TemplateExt). - Funcs(t.funcMap) - - // Parse all files provided - if len(files) > 0 { - for k, v := range files { - files[k] = fmt.Sprintf("%s/%s%s", t.templatesPath, v, config.TemplateExt) - } - - parsed, err = parsed.ParseFiles(files...) - if err != nil { - return err - } - } - - // Parse all templates within the provided directories - for _, dir := range directories { - dir = fmt.Sprintf("%s/%s/*%s", t.templatesPath, dir, config.TemplateExt) - parsed, err = parsed.ParseGlob(dir) - if err != nil { - return err - } - } - - // Store the template so this process only happens once - t.templateCache.Store(cacheKey, parsed) +// Parse creates a template build operation +func (t *TemplateRenderer) Parse() *templateBuilder { + return &templateBuilder{ + renderer: t, + build: &templateBuild{}, } - - return nil -} - -// Execute executes a cached template with the data provided -// See Parse() for an explanation of the parameters -func (t *TemplateRenderer) Execute(cacheGroup, cacheID, baseName string, data interface{}) (*bytes.Buffer, error) { - tmpl, err := t.Load(cacheGroup, cacheID) - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - err = tmpl.ExecuteTemplate(buf, baseName+config.TemplateExt, data) - if err != nil { - return nil, err - } - - return buf, nil -} - -// ParseAndExecute is a wrapper around Parse() and Execute() -func (t *TemplateRenderer) ParseAndExecute(cacheGroup, cacheID, baseName string, files []string, directories []string, data interface{}) (*bytes.Buffer, error) { - var buf *bytes.Buffer - var err error - - if err = t.Parse(cacheGroup, cacheID, baseName, files, directories); err != nil { - return nil, err - } - if buf, err = t.Execute(cacheGroup, cacheID, baseName, data); err != nil { - return nil, err - } - - return buf, nil -} - -// Load loads a template from the cache -func (t *TemplateRenderer) Load(cacheGroup, cacheID string) (*template.Template, error) { - load, ok := t.templateCache.Load(t.getCacheKey(cacheGroup, cacheID)) - if !ok { - return nil, errors.New("uncached page template requested") - } - - tmpl, ok := load.(*template.Template) - if !ok { - return nil, errors.New("unable to cast cached template") - } - - return tmpl, nil } // GetTemplatesPath gets the complete path to the templates directory @@ -172,6 +87,147 @@ func (t *TemplateRenderer) GetTemplatesPath() string { } // getCacheKey gets a cache key for a given group and ID -func (t *TemplateRenderer) getCacheKey(cacheGroup, cacheID string) string { - return fmt.Sprintf("%s:%s", cacheGroup, cacheID) +func (t *TemplateRenderer) getCacheKey(group, key string) string { + if group != "" { + return fmt.Sprintf("%s:%s", group, key) + } + return key +} + +// parse parses a set of templates and caches them for quick execution +// If the application environment is set to local, the cache will be bypassed and templates will be +// parsed upon each request so hot-reloading is possible without restarts. +// Also included will be the function map provided by the funcmap package. +func (t *TemplateRenderer) parse(build *templateBuild) (*TemplateParsed, error) { + var tp *TemplateParsed + var err error + + switch { + case build.key == "": + return nil, errors.New("cannot parse template without key") + case len(build.files) == 0 && len(build.directories) == 0: + return nil, errors.New("cannot parse template without files or directories") + case build.base == "": + return nil, errors.New("cannot parse template without base") + } + + // Generate the cache key + cacheKey := t.getCacheKey(build.group, build.key) + + // Check if the template has not yet been parsed or if the app environment is local, so that + // templates reflect changes without having the restart the server + if tp, err = t.Load(build.group, build.key); err != nil || t.config.App.Environment == config.EnvLocal { + // Initialize the parsed template with the function map + parsed := template.New(build.base + config.TemplateExt). + Funcs(t.funcMap) + + // Parse all files provided + if len(build.files) > 0 { + for k, v := range build.files { + build.files[k] = fmt.Sprintf("%s/%s%s", t.templatesPath, v, config.TemplateExt) + } + + parsed, err = parsed.ParseFiles(build.files...) + if err != nil { + return nil, err + } + } + + // Parse all templates within the provided directories + for _, dir := range build.directories { + dir = fmt.Sprintf("%s/%s/*%s", t.templatesPath, dir, config.TemplateExt) + parsed, err = parsed.ParseGlob(dir) + if err != nil { + return nil, err + } + } + + // Store the template so this process only happens once + tp = &TemplateParsed{ + Template: parsed, + build: build, + } + t.templateCache.Store(cacheKey, tp) + } + + return tp, nil +} + +// Load loads a template from the cache +func (t *TemplateRenderer) Load(group, key string) (*TemplateParsed, error) { + load, ok := t.templateCache.Load(t.getCacheKey(group, key)) + if !ok { + return nil, errors.New("uncached page template requested") + } + + tmpl, ok := load.(*TemplateParsed) + if !ok { + return nil, errors.New("unable to cast cached template") + } + + return tmpl, nil +} + +// Execute executes a template with the given data and provides the output +func (t *TemplateParsed) Execute(data interface{}) (*bytes.Buffer, error) { + if t.Template == nil { + return nil, errors.New("cannot execute template: template not initialized") + } + + buf := new(bytes.Buffer) + err := t.Template.ExecuteTemplate(buf, t.build.base+config.TemplateExt, data) + if err != nil { + return nil, err + } + + return buf, nil +} + +// Group sets the cache group for the template being built +func (t *templateBuilder) Group(group string) *templateBuilder { + t.build.group = group + return t +} + +// Key sets the cache key for the template being built +func (t *templateBuilder) Key(key string) *templateBuilder { + t.build.key = key + return t +} + +// Base sets the name of the base template to be used during template parsing and execution. +// This should be only the file name without a directory or extension. +func (t *templateBuilder) Base(base string) *templateBuilder { + t.build.base = base + return t +} + +// Files sets a list of template files to include in the parse. +// This should not include the file extension and the paths should be relative to the templates directory. +func (t *templateBuilder) Files(files ...string) *templateBuilder { + t.build.files = files + return t +} + +// Directories sets a list of directories that all template files within will be parsed. +// The paths should be relative to the templates directory. +func (t *templateBuilder) Directories(directories ...string) *templateBuilder { + t.build.directories = directories + return t +} + +// Store parsed the templates and stores them in the cache +func (t *templateBuilder) Store() (*TemplateParsed, error) { + return t.renderer.parse(t.build) +} + +// Execute executes the template with the given data. +// If the template has not already been cached, this will parse and cache the template +func (t *templateBuilder) Execute(data interface{}) (*bytes.Buffer, error) { + tp, err := t.Store() + if err != nil { + return nil, err + } + + return tp.Execute(data) } diff --git a/services/template_renderer_test.go b/services/template_renderer_test.go index d51a46c..65d960c 100644 --- a/services/template_renderer_test.go +++ b/services/template_renderer_test.go @@ -19,13 +19,14 @@ func TestTemplateRenderer(t *testing.T) { assert.Error(t, err) // Parse in to the cache - err = c.TemplateRenderer.Parse( - group, - id, - "htmx", - []string{"htmx", "pages/error"}, - []string{"components"}, - ) + tpl, err := c.TemplateRenderer. + Parse(). + Group(group). + Key(id). + Base("htmx"). + Files("htmx", "pages/error"). + Directories("components"). + Store() require.NoError(t, err) // Should exist now @@ -41,7 +42,7 @@ func TestTemplateRenderer(t *testing.T) { for _, f := range components { expectedTemplates[f.Name()] = true } - for _, v := range parsed.Templates() { + for _, v := range parsed.Template.Templates() { delete(expectedTemplates, v.Name()) } assert.Empty(t, expectedTemplates) @@ -51,19 +52,20 @@ func TestTemplateRenderer(t *testing.T) { }{ StatusCode: 500, } - buf, err := c.TemplateRenderer.Execute(group, id, "htmx", data) + buf, err := tpl.Execute(data) require.NoError(t, err) require.NotNil(t, buf) assert.Contains(t, buf.String(), "Please try again") - buf, err = c.TemplateRenderer.ParseAndExecute( - group, - id, - "htmx", - []string{"htmx", "pages/error"}, - []string{"components"}, - data, - ) + buf, err = c.TemplateRenderer. + Parse(). + Group(group). + Key(id). + Base("htmx"). + Files("htmx", "pages/error"). + Directories("components"). + Execute(data) + require.NoError(t, err) require.NotNil(t, buf) assert.Contains(t, buf.String(), "Please try again")