From 2f7ceb5774c8715688cc7199183291c1b19259e2 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Tue, 28 Nov 2023 09:39:14 -0700 Subject: [PATCH] templates: Offically make templates extensible (#5939) * templates: Offically make templates extensible This supercedes #4757 (and #4568) by making template extensions configurable. The previous implementation was never documented AFAIK and had only 1 consumer, which I'll notify as a courtesy. * templates: Add 'maybe' function for optional components * Try to fix lint error --- modules/caddyhttp/templates/caddyfile.go | 26 ++++++++++++ modules/caddyhttp/templates/templates.go | 24 ++++++----- modules/caddyhttp/templates/tplcontext.go | 50 ++++++++++++++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/modules/caddyhttp/templates/caddyfile.go b/modules/caddyhttp/templates/caddyfile.go index 06ca3e26096..c3039aa890e 100644 --- a/modules/caddyhttp/templates/caddyfile.go +++ b/modules/caddyhttp/templates/caddyfile.go @@ -15,6 +15,9 @@ package templates import ( + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) @@ -49,6 +52,29 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) if !h.Args(&t.FileRoot) { return nil, h.ArgErr() } + case "extensions": + if h.NextArg() { + return nil, h.ArgErr() + } + if t.ExtensionsRaw != nil { + return nil, h.Err("extensions already specified") + } + for nesting := h.Nesting(); h.NextBlock(nesting); { + extensionModuleName := h.Val() + modID := "http.handlers.templates.functions." + extensionModuleName + unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID) + if err != nil { + return nil, err + } + cf, ok := unm.(CustomFunctions) + if !ok { + return nil, h.Errf("module %s (%T) does not provide template functions", modID, unm) + } + if t.ExtensionsRaw == nil { + t.ExtensionsRaw = make(caddy.ModuleMap) + } + t.ExtensionsRaw[extensionModuleName] = caddyconfig.JSON(cf, nil) + } } } } diff --git a/modules/caddyhttp/templates/templates.go b/modules/caddyhttp/templates/templates.go index 4da02b580ca..418f09e531c 100644 --- a/modules/caddyhttp/templates/templates.go +++ b/modules/caddyhttp/templates/templates.go @@ -23,6 +23,8 @@ import ( "strings" "text/template" + "go.uber.org/zap" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) @@ -319,7 +321,12 @@ type Templates struct { // the opening and closing delimiters. Default: `["{{", "}}"]` Delimiters []string `json:"delimiters,omitempty"` + // Extensions adds functions to the template's func map. These often + // act as components on web pages, for example. + ExtensionsRaw caddy.ModuleMap `json:"match,omitempty" caddy:"namespace=http.handlers.templates.functions"` + customFuncs []template.FuncMap + logger *zap.Logger } // Customfunctions is the interface for registering custom template functions. @@ -338,17 +345,14 @@ func (Templates) CaddyModule() caddy.ModuleInfo { // Provision provisions t. func (t *Templates) Provision(ctx caddy.Context) error { - fnModInfos := caddy.GetModules("http.handlers.templates.functions") - customFuncs := make([]template.FuncMap, 0, len(fnModInfos)) - for _, modInfo := range fnModInfos { - mod := modInfo.New() - fnMod, ok := mod.(CustomFunctions) - if !ok { - return fmt.Errorf("module %q does not satisfy the CustomFunctions interface", modInfo.ID) - } - customFuncs = append(customFuncs, fnMod.CustomTemplateFunctions()) + t.logger = ctx.Logger() + mods, err := ctx.LoadModule(t, "ExtensionsRaw") + if err != nil { + return fmt.Errorf("loading template extensions: %v", err) + } + for _, modIface := range mods.(map[string]any) { + t.customFuncs = append(t.customFuncs, modIface.(CustomFunctions).CustomTemplateFunctions()) } - t.customFuncs = customFuncs if t.MIMETypes == nil { t.MIMETypes = defaultMIMETypes diff --git a/modules/caddyhttp/templates/tplcontext.go b/modules/caddyhttp/templates/tplcontext.go index 8b3d6bfc486..a66a0c3054c 100644 --- a/modules/caddyhttp/templates/tplcontext.go +++ b/modules/caddyhttp/templates/tplcontext.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "path" + "reflect" "strconv" "strings" "sync" @@ -37,6 +38,7 @@ import ( "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" gmhtml "github.com/yuin/goldmark/renderer/html" + "go.uber.org/zap" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -57,7 +59,7 @@ type TemplateContext struct { // NewTemplate returns a new template intended to be evaluated with this // context, as it is initialized with configuration from this context. func (c *TemplateContext) NewTemplate(tplName string) *template.Template { - c.tpl = template.New(tplName) + c.tpl = template.New(tplName).Option("missingkey=zero") // customize delimiters, if applicable if c.config != nil && len(c.config.Delimiters) == 2 { @@ -88,6 +90,7 @@ func (c *TemplateContext) NewTemplate(tplName string) *template.Template { "fileExists": c.funcFileExists, "httpError": c.funcHTTPError, "humanize": c.funcHumanize, + "maybe": c.funcMaybe, }) return c.tpl } @@ -492,6 +495,51 @@ func (c TemplateContext) funcHumanize(formatType, data string) (string, error) { return "", fmt.Errorf("no know function was given") } +// funcMaybe invokes the plugged-in function named functionName if it is plugged in +// (is a module in the 'http.handlers.templates.functions' namespace). If it is not +// available, a log message is emitted. +// +// The first argument is the function name, and the rest of the arguments are +// passed on to the actual function. +// +// This function is useful for executing templates that use components that may be +// considered as optional in some cases (like during local development) where you do +// not want to require everyone to have a custom Caddy build to be able to execute +// your template. +// +// NOTE: This function is EXPERIMENTAL and subject to change or removal. +func (c TemplateContext) funcMaybe(functionName string, args ...any) (any, error) { + for _, funcMap := range c.CustomFuncs { + if fn, ok := funcMap[functionName]; ok { + val := reflect.ValueOf(fn) + if val.Kind() != reflect.Func { + continue + } + argVals := make([]reflect.Value, len(args)) + for i, arg := range args { + argVals[i] = reflect.ValueOf(arg) + } + returnVals := val.Call(argVals) + switch len(returnVals) { + case 0: + return "", nil + case 1: + return returnVals[0].Interface(), nil + case 2: + var err error + if !returnVals[1].IsNil() { + err = returnVals[1].Interface().(error) + } + return returnVals[0].Interface(), err + default: + return nil, fmt.Errorf("maybe %s: invalid number of return values: %d", functionName, len(returnVals)) + } + } + } + c.config.logger.Named("maybe").Warn("template function could not be found; ignoring invocation", zap.String("name", functionName)) + return "", nil +} + // WrappedHeader wraps niladic functions so that they // can be used in templates. (Template functions must // return a value.)