2021-12-19 17:09:01 -08:00
|
|
|
package services
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"html/template"
|
2023-12-12 17:07:58 -08:00
|
|
|
"io/fs"
|
2024-06-15 12:34:24 -07:00
|
|
|
"net/http"
|
2021-12-19 17:09:01 -08:00
|
|
|
"sync"
|
|
|
|
|
2024-07-13 21:20:37 -07:00
|
|
|
"github.com/a-h/templ"
|
2024-06-14 09:35:35 -07:00
|
|
|
"github.com/labstack/echo/v4"
|
2024-07-09 17:57:05 -07:00
|
|
|
|
|
|
|
"git.grosinger.net/tgrosinger/saasitone/config"
|
|
|
|
"git.grosinger.net/tgrosinger/saasitone/pkg/context"
|
|
|
|
"git.grosinger.net/tgrosinger/saasitone/pkg/log"
|
|
|
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
|
|
|
"git.grosinger.net/tgrosinger/saasitone/templates"
|
2021-12-19 17:09:01 -08:00
|
|
|
)
|
|
|
|
|
2024-06-15 12:34:24 -07:00
|
|
|
// cachedPageGroup stores the cache group for cached pages
|
|
|
|
const cachedPageGroup = "page"
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
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
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// funcMap stores the template function map
|
|
|
|
funcMap template.FuncMap
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// config stores application configuration
|
|
|
|
config *config.Config
|
2024-06-15 12:34:24 -07:00
|
|
|
|
|
|
|
// cache stores the cache client
|
|
|
|
cache *CacheClient
|
2022-01-19 06:14:18 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
2024-06-15 12:34:24 -07:00
|
|
|
|
|
|
|
// CachedPage is what is used to store a rendered Page in the cache
|
|
|
|
CachedPage struct {
|
|
|
|
// URL stores the URL of the requested page
|
|
|
|
URL string
|
|
|
|
|
|
|
|
// HTML stores the complete HTML of the rendered Page
|
|
|
|
HTML []byte
|
|
|
|
|
|
|
|
// StatusCode stores the HTTP status code
|
|
|
|
StatusCode int
|
|
|
|
|
|
|
|
// Headers stores the HTTP headers
|
|
|
|
Headers map[string]string
|
|
|
|
}
|
2022-01-19 06:14:18 -08:00
|
|
|
)
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2021-12-22 16:18:33 -08:00
|
|
|
// NewTemplateRenderer creates a new TemplateRenderer
|
2024-06-15 12:34:24 -07:00
|
|
|
func NewTemplateRenderer(cfg *config.Config, cache *CacheClient, fm template.FuncMap) *TemplateRenderer {
|
2023-12-10 06:33:34 -08:00
|
|
|
return &TemplateRenderer{
|
2021-12-19 17:09:01 -08:00
|
|
|
templateCache: sync.Map{},
|
2024-06-15 12:34:24 -07:00
|
|
|
funcMap: fm,
|
2021-12-19 17:09:01 -08:00
|
|
|
config: cfg,
|
2024-06-15 12:34:24 -07:00
|
|
|
cache: cache,
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// Parse creates a template build operation
|
|
|
|
func (t *TemplateRenderer) Parse() *templateBuilder {
|
|
|
|
return &templateBuilder{
|
|
|
|
renderer: t,
|
|
|
|
build: &templateBuild{},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-13 21:20:37 -07:00
|
|
|
func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, content templ.Component) error {
|
|
|
|
// Use the app name in configuration if a value was not set
|
|
|
|
if page.AppName == "" {
|
|
|
|
page.AppName = t.config.App.Name
|
|
|
|
}
|
|
|
|
|
|
|
|
var err error
|
|
|
|
var buf bytes.Buffer
|
|
|
|
if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted {
|
|
|
|
// This is an HTMX non-boosted request.
|
|
|
|
// Only partial content should be rendered.
|
|
|
|
err = content.Render(ctx.Request().Context(), &buf)
|
|
|
|
} else {
|
2024-07-27 12:28:45 -07:00
|
|
|
err = page.LayoutComponent(page, content).Render(ctx.Request().Context(), &buf)
|
2024-07-13 21:20:37 -07:00
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("failed to parse and execute templates: %s", err),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set the status code
|
|
|
|
ctx.Response().Status = page.StatusCode
|
|
|
|
|
|
|
|
// Set any headers
|
|
|
|
for k, v := range page.Headers {
|
|
|
|
ctx.Response().Header().Set(k, v)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Apply the HTMX response, if one
|
|
|
|
if page.HTMX.Response != nil {
|
|
|
|
page.HTMX.Response.Apply(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Cache this page, if caching was enabled
|
|
|
|
t.cachePage(ctx, page, &buf)
|
2024-06-15 12:34:24 -07:00
|
|
|
return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes())
|
|
|
|
}
|
|
|
|
|
|
|
|
// cachePage caches the HTML for a given Page if the Page has caching enabled
|
|
|
|
func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *bytes.Buffer) {
|
|
|
|
if !page.Cache.Enabled || page.IsAuth {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// If no expiration time was provided, default to the configuration value
|
|
|
|
if page.Cache.Expiration == 0 {
|
|
|
|
page.Cache.Expiration = t.config.Cache.Expiration.Page
|
|
|
|
}
|
|
|
|
|
|
|
|
// Extract the headers
|
|
|
|
headers := make(map[string]string)
|
|
|
|
for k, v := range ctx.Response().Header() {
|
|
|
|
headers[k] = v[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
// The request URL is used as the cache key so the middleware can serve the
|
|
|
|
// cached page on matching requests
|
|
|
|
key := ctx.Request().URL.String()
|
2024-06-22 07:34:26 -07:00
|
|
|
cp := &CachedPage{
|
2024-06-15 12:34:24 -07:00
|
|
|
URL: key,
|
|
|
|
HTML: html.Bytes(),
|
|
|
|
Headers: headers,
|
|
|
|
StatusCode: ctx.Response().Status,
|
|
|
|
}
|
|
|
|
|
|
|
|
err := t.cache.
|
|
|
|
Set().
|
|
|
|
Group(cachedPageGroup).
|
|
|
|
Key(key).
|
|
|
|
Tags(page.Cache.Tags...).
|
|
|
|
Expiration(page.Cache.Expiration).
|
|
|
|
Data(cp).
|
|
|
|
Save(ctx.Request().Context())
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case err == nil:
|
|
|
|
log.Ctx(ctx).Debug("cached page")
|
|
|
|
case !context.IsCanceledError(err):
|
|
|
|
log.Ctx(ctx).Error("failed to cache page",
|
|
|
|
"error", err,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetCachedPage attempts to fetch a cached page for a given URL
|
|
|
|
func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedPage, error) {
|
|
|
|
p, err := t.cache.
|
|
|
|
Get().
|
|
|
|
Group(cachedPageGroup).
|
|
|
|
Key(url).
|
|
|
|
Fetch(ctx.Request().Context())
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return p.(*CachedPage), nil
|
|
|
|
}
|
2024-07-27 12:07:17 -07:00
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// getCacheKey gets a cache key for a given group and ID
|
|
|
|
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
|
2021-12-25 08:21:26 -08:00
|
|
|
// 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.
|
2022-01-19 06:14:18 -08:00
|
|
|
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)
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2021-12-19 17:37:51 -08:00
|
|
|
// 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
|
2022-01-19 06:14:18 -08:00
|
|
|
if tp, err = t.Load(build.group, build.key); err != nil || t.config.App.Environment == config.EnvLocal {
|
2021-12-19 17:09:01 -08:00
|
|
|
// Initialize the parsed template with the function map
|
2022-01-19 06:14:18 -08:00
|
|
|
parsed := template.New(build.base + config.TemplateExt).
|
2021-12-19 17:09:01 -08:00
|
|
|
Funcs(t.funcMap)
|
|
|
|
|
2023-12-12 17:07:58 -08:00
|
|
|
// Format the requested files
|
|
|
|
for k, v := range build.files {
|
|
|
|
build.files[k] = fmt.Sprintf("%s%s", v, config.TemplateExt)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Include all files within the requested directories
|
|
|
|
for k, v := range build.directories {
|
|
|
|
build.directories[k] = fmt.Sprintf("%s/*%s", v, config.TemplateExt)
|
|
|
|
}
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2023-12-12 17:07:58 -08:00
|
|
|
// Get the templates
|
|
|
|
var tpl fs.FS
|
|
|
|
if t.config.App.Environment == config.EnvLocal {
|
|
|
|
tpl = templates.GetOS()
|
|
|
|
} else {
|
|
|
|
tpl = templates.Get()
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2023-12-12 17:07:58 -08:00
|
|
|
// Parse the templates
|
|
|
|
parsed, err = parsed.ParseFS(tpl, append(build.files, build.directories...)...)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Store the template so this process only happens once
|
2022-01-19 06:14:18 -08:00
|
|
|
tp = &TemplateParsed{
|
|
|
|
Template: parsed,
|
|
|
|
build: build,
|
|
|
|
}
|
|
|
|
t.templateCache.Store(cacheKey, tp)
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
return tp, nil
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// 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")
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
tmpl, ok := load.(*TemplateParsed)
|
|
|
|
if !ok {
|
|
|
|
return nil, errors.New("unable to cast cached template")
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
return tmpl, nil
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// Execute executes a template with the given data and provides the output
|
2023-12-16 08:07:20 -08:00
|
|
|
func (t *TemplateParsed) Execute(data any) (*bytes.Buffer, error) {
|
2022-01-19 06:14:18 -08:00
|
|
|
if t.Template == nil {
|
|
|
|
return nil, errors.New("cannot execute template: template not initialized")
|
2021-12-25 08:21:26 -08:00
|
|
|
}
|
2022-01-19 06:14:18 -08:00
|
|
|
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
err := t.Template.ExecuteTemplate(buf, t.build.base+config.TemplateExt, data)
|
|
|
|
if err != nil {
|
2021-12-25 08:21:26 -08:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return buf, nil
|
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// Group sets the cache group for the template being built
|
|
|
|
func (t *templateBuilder) Group(group string) *templateBuilder {
|
|
|
|
t.build.group = group
|
|
|
|
return t
|
|
|
|
}
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// Key sets the cache key for the template being built
|
|
|
|
func (t *templateBuilder) Key(key string) *templateBuilder {
|
|
|
|
t.build.key = key
|
|
|
|
return t
|
|
|
|
}
|
2021-12-19 17:09:01 -08:00
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// 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
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// 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
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|
|
|
|
|
2022-01-19 06:14:18 -08:00
|
|
|
// 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
|
2023-12-16 08:07:20 -08:00
|
|
|
func (t *templateBuilder) Execute(data any) (*bytes.Buffer, error) {
|
2022-01-19 06:14:18 -08:00
|
|
|
tp, err := t.Store()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return tp.Execute(data)
|
2021-12-19 17:09:01 -08:00
|
|
|
}
|