core/templates/pongo/engine.go
Alex Dunmow 32c6528162 feat(templates): add HTMLComponent interface and first-class pongo2 engine
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>
2026-06-02 23:07:11 +08:00

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()
}