Decouple TemplateFunc from templ.Component by introducing a generic HTMLComponent interface that both templ and pongo2 satisfy via Go structural typing. Add a complete pongo2 rendering engine in templates/pongo/ with page templates, block templates (with BlockContext injection and icon processing), template overrides, and email wrappers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
6.0 KiB
Go
173 lines
6.0 KiB
Go
package pongo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"io/fs"
|
|
|
|
"github.com/flosch/pongo2/v6"
|
|
|
|
"git.dev.alexdunmow.com/block/core/blocks"
|
|
"git.dev.alexdunmow.com/block/core/templates"
|
|
)
|
|
|
|
// Engine provides first-class pongo2 template support for BlockNinja plugins.
|
|
// Plugins create an Engine with their embedded template FS, then call
|
|
// MustPageTemplate / MustBlockTemplate to get functions compatible with
|
|
// the template and block registries.
|
|
type Engine struct {
|
|
set *pongo2.TemplateSet
|
|
stylePaths []string
|
|
}
|
|
|
|
// NewEngine creates a pongo2 Engine.
|
|
// pluginFS should contain the plugin's .html templates.
|
|
// stylePaths are CSS URLs included in the page <head> via bn.Head.
|
|
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
|
|
loader := &multiLoader{
|
|
loaders: []pongo2.TemplateLoader{
|
|
&fsLoader{fsys: pluginFS},
|
|
&fsLoader{fsys: baseFS},
|
|
},
|
|
}
|
|
set := pongo2.NewSet("plugin", loader)
|
|
return &Engine{set: set, stylePaths: stylePaths}
|
|
}
|
|
|
|
// pongoComponent wraps a pongo2 template execution as an HTMLComponent.
|
|
type pongoComponent struct {
|
|
tpl *pongo2.Template
|
|
ctx pongo2.Context
|
|
}
|
|
|
|
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
|
return c.tpl.ExecuteWriter(c.ctx, w)
|
|
}
|
|
|
|
// MustPageTemplate parses a pongo2 page template and returns a TemplateFunc.
|
|
// Panics on parse error.
|
|
//
|
|
// Available context variables in page templates:
|
|
//
|
|
// {{ head_html|safe }} — full <head> element
|
|
// {{ body_end_html|safe }} — body-end scripts and admin toolbar
|
|
// {{ admin_banner_html|safe }} — maintenance/coming-soon admin banner
|
|
// {{ title }} — page title
|
|
// {{ slots.header|safe }} — rendered slot HTML
|
|
// {{ theme_mode }} — "light", "dark", or "system"
|
|
// {{ theme_css }} — raw CSS custom properties
|
|
// {{ css_hash }} — cache-busting hash
|
|
// {{ site_settings }} — bn.SiteSettingsData struct
|
|
// {{ page_meta }} — bn.PageMeta struct
|
|
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
|
tpl := pongo2.Must(e.set.FromFile(name))
|
|
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
|
pongoCtx := e.buildPageContext(ctx, doc)
|
|
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
|
|
}
|
|
}
|
|
|
|
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
|
|
// Panics on parse error.
|
|
//
|
|
// The content map fields are available directly as template variables.
|
|
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
|
|
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
|
tpl := pongo2.Must(e.set.FromFile(name))
|
|
return func(ctx context.Context, content map[string]any) string {
|
|
pongoCtx := buildBlockContext(ctx, content)
|
|
out, err := tpl.Execute(pongoCtx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return blocks.ProcessIcons(out)
|
|
}
|
|
}
|
|
|
|
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
|
|
// values before rendering. Content keys override defaults.
|
|
func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc {
|
|
tpl := pongo2.Must(e.set.FromFile(name))
|
|
return func(ctx context.Context, content map[string]any) string {
|
|
merged := make(map[string]any, len(defaults)+len(content))
|
|
for k, v := range defaults {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range content {
|
|
merged[k] = v
|
|
}
|
|
pongoCtx := buildBlockContext(ctx, merged)
|
|
out, err := tpl.Execute(pongoCtx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return blocks.ProcessIcons(out)
|
|
}
|
|
}
|
|
|
|
// MustTemplateOverride parses a pongo2 template for use as a block template
|
|
// override (e.g., custom heading/text styling). Same as MustBlockTemplate
|
|
// but named distinctly for clarity at the call site.
|
|
func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
|
|
return e.MustBlockTemplate(name)
|
|
}
|
|
|
|
// MustEmailWrapper parses a pongo2 email template and returns an
|
|
// EmailWrapperFunc. Panics on parse error.
|
|
//
|
|
// Available context variables:
|
|
//
|
|
// {{ body|safe }} — the email body HTML
|
|
// {{ site_name }} — site name
|
|
// {{ site_url }} — site URL
|
|
// {{ logo_url }} — site logo URL
|
|
// {{ unsubscribe_url }} — unsubscribe link (may be empty)
|
|
// {{ preview_text }} — email preview/preheader text
|
|
// {{ colors.primary }} — primary hex color
|
|
// {{ colors.secondary }} — secondary hex color
|
|
// {{ colors.background }} — background hex color
|
|
// {{ colors.foreground }} — foreground hex color
|
|
// {{ colors.border }} — border hex color
|
|
// {{ colors.muted }} — muted hex color
|
|
// (and all other EmailColors fields as lowercase keys)
|
|
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
|
tpl := pongo2.Must(e.set.FromFile(name))
|
|
return func(body string, ctx templates.EmailContext) string {
|
|
pongoCtx := pongo2.Context{
|
|
"body": body,
|
|
"site_name": ctx.SiteSettings.SiteName,
|
|
"site_url": ctx.SiteSettings.SiteURL,
|
|
"logo_url": ctx.SiteSettings.LogoURL,
|
|
"support_email": ctx.SiteSettings.SupportEmail,
|
|
"unsubscribe_url": ctx.UnsubscribeURL,
|
|
"preview_text": ctx.PreviewText,
|
|
"colors": map[string]any{
|
|
"primary": ctx.Colors.Primary,
|
|
"primaryForeground": ctx.Colors.PrimaryForeground,
|
|
"secondary": ctx.Colors.Secondary,
|
|
"secondaryForeground": ctx.Colors.SecondaryForeground,
|
|
"background": ctx.Colors.Background,
|
|
"foreground": ctx.Colors.Foreground,
|
|
"muted": ctx.Colors.Muted,
|
|
"mutedForeground": ctx.Colors.MutedForeground,
|
|
"border": ctx.Colors.Border,
|
|
"card": ctx.Colors.Card,
|
|
"cardForeground": ctx.Colors.CardForeground,
|
|
},
|
|
}
|
|
out, err := tpl.Execute(pongoCtx)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// renderComponent renders any HTMLComponent to a string.
|
|
func renderComponent(ctx context.Context, c templates.HTMLComponent) string {
|
|
var buf bytes.Buffer
|
|
_ = c.Render(ctx, &buf)
|
|
return buf.String()
|
|
}
|