feat(pongo): non-panicking Engine compile variants + global 'lines' filter (WO-WZ-021)
PageTemplate/BlockTemplate/EmailWrapper return errors instead of panicking so hosts can compile artifact-supplied templates at load time (codeless themes) without a malformed artifact taking down the process; Must* variants delegate. The 'lines' filter splits a string into trimmed non-empty lines — pongo2 string literals cannot express \n, so multi-line textarea fields (per-line <div> addresses) were previously inexpressible in templates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
87bbf9fe46
commit
630dfc72b6
@ -62,11 +62,25 @@ func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
|||||||
// {{ site_settings }} — bn.SiteSettingsData struct
|
// {{ site_settings }} — bn.SiteSettingsData struct
|
||||||
// {{ page_meta }} — bn.PageMeta struct
|
// {{ page_meta }} — bn.PageMeta struct
|
||||||
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
||||||
tpl := pongo2.Must(e.set.FromFile(name))
|
fn, err := e.PageTemplate(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageTemplate is MustPageTemplate without the panic — for hosts compiling
|
||||||
|
// artifact-supplied templates at load time (a malformed artifact must fail
|
||||||
|
// the load, not the process).
|
||||||
|
func (e *Engine) PageTemplate(name string) (templates.TemplateFunc, error) {
|
||||||
|
tpl, err := e.set.FromFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
||||||
pongoCtx := e.buildPageContext(ctx, doc)
|
pongoCtx := e.buildPageContext(ctx, doc)
|
||||||
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
|
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
|
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
|
||||||
@ -75,7 +89,19 @@ func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
|||||||
// The content map fields are available directly as template variables.
|
// The content map fields are available directly as template variables.
|
||||||
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
|
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
|
||||||
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
||||||
tpl := pongo2.Must(e.set.FromFile(name))
|
fn, err := e.BlockTemplate(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockTemplate is MustBlockTemplate without the panic (see PageTemplate).
|
||||||
|
func (e *Engine) BlockTemplate(name string) (blocks.BlockFunc, error) {
|
||||||
|
tpl, err := e.set.FromFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return func(ctx context.Context, content map[string]any) string {
|
return func(ctx context.Context, content map[string]any) string {
|
||||||
pongoCtx := buildBlockContext(ctx, content)
|
pongoCtx := buildBlockContext(ctx, content)
|
||||||
out, err := tpl.Execute(pongoCtx)
|
out, err := tpl.Execute(pongoCtx)
|
||||||
@ -83,7 +109,7 @@ func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return blocks.ProcessIcons(out)
|
return blocks.ProcessIcons(out)
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
|
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
|
||||||
@ -129,7 +155,19 @@ func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
|
|||||||
// {{ colors.muted }} — muted hex color
|
// {{ colors.muted }} — muted hex color
|
||||||
// (and all other EmailColors fields as lowercase keys)
|
// (and all other EmailColors fields as lowercase keys)
|
||||||
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
||||||
tpl := pongo2.Must(e.set.FromFile(name))
|
fn, err := e.EmailWrapper(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailWrapper is MustEmailWrapper without the panic (see PageTemplate).
|
||||||
|
func (e *Engine) EmailWrapper(name string) (templates.EmailWrapperFunc, error) {
|
||||||
|
tpl, err := e.set.FromFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return func(body string, ctx templates.EmailContext) string {
|
return func(body string, ctx templates.EmailContext) string {
|
||||||
pongoCtx := pongo2.Context{
|
pongoCtx := pongo2.Context{
|
||||||
"body": body,
|
"body": body,
|
||||||
@ -158,7 +196,7 @@ func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderComponent renders any HTMLComponent to a string.
|
// renderComponent renders any HTMLComponent to a string.
|
||||||
|
|||||||
66
templates/pongo/engine_test.go
Normal file
66
templates/pongo/engine_test.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNonPanickingCompileVariants(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"templates/theme/default.ninjatpl": {Data: []byte(`<main>{{ slots.main|safe }}</main>`)},
|
||||||
|
"templates/overrides/theme/heading.ninjatpl": {Data: []byte(`<h2>{{ text }}</h2>`)},
|
||||||
|
"templates/email/theme.ninjatpl": {Data: []byte(`<table>{{ body|safe }} {{ colors.mutedForeground }}</table>`)},
|
||||||
|
"templates/broken.ninjatpl": {Data: []byte(`{% if %}`)},
|
||||||
|
}
|
||||||
|
e := NewEngine(fsys)
|
||||||
|
|
||||||
|
if _, err := e.PageTemplate("templates/theme/default.ninjatpl"); err != nil {
|
||||||
|
t.Fatalf("PageTemplate: %v", err)
|
||||||
|
}
|
||||||
|
bf, err := e.BlockTemplate("templates/overrides/theme/heading.ninjatpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BlockTemplate: %v", err)
|
||||||
|
}
|
||||||
|
if got := bf(t.Context(), map[string]any{"text": "hi"}); got != "<h2>hi</h2>" {
|
||||||
|
t.Errorf("override render = %q", got)
|
||||||
|
}
|
||||||
|
ef, err := e.EmailWrapper("templates/email/theme.ninjatpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EmailWrapper: %v", err)
|
||||||
|
}
|
||||||
|
ectx := templates.EmailContext{}
|
||||||
|
ectx.Colors.MutedForeground = "#6c5b35"
|
||||||
|
if got := ef("<p>x</p>", ectx); got != "<table><p>x</p> #6c5b35</table>" {
|
||||||
|
t.Errorf("email render = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed templates return errors — no panic (hot-install safety).
|
||||||
|
if _, err := e.PageTemplate("templates/broken.ninjatpl"); err == nil {
|
||||||
|
t.Error("PageTemplate on broken template: want error")
|
||||||
|
}
|
||||||
|
if _, err := e.BlockTemplate("templates/missing.ninjatpl"); err == nil {
|
||||||
|
t.Error("BlockTemplate on missing file: want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinesFilter(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"t.ninjatpl": {Data: []byte(`{% for l in address|lines %}<div>{{ l }}</div>{% endfor %}`)},
|
||||||
|
}
|
||||||
|
e := NewEngine(fsys)
|
||||||
|
bf, err := e.BlockTemplate("t.ninjatpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := bf(t.Context(), map[string]any{"address": "1 Meridian Plaza\r\n Floor 2 \n\nNew Gotham"})
|
||||||
|
want := "<div>1 Meridian Plaza</div><div>Floor 2</div><div>New Gotham</div>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("lines render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "\n") {
|
||||||
|
t.Error("unexpected raw newline in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
35
templates/pongo/filters.go
Normal file
35
templates/pongo/filters.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom pongo2 filters registered process-globally (pongo2's filter registry
|
||||||
|
// is package-level, so these are available to every TemplateSet in the
|
||||||
|
// process — the plugin Engine, the CMS definition engine, and the powered
|
||||||
|
// renderer alike).
|
||||||
|
//
|
||||||
|
// lines — split a string into its non-empty lines (newline-separated).
|
||||||
|
// pongo2 string literals cannot contain \n, so {{ v|split:"\n" }}
|
||||||
|
// is inexpressible; multi-line textarea fields (e.g. postal
|
||||||
|
// addresses rendered one <div> per line) need this filter:
|
||||||
|
//
|
||||||
|
// {% for line in address|lines %}<div>{{ line }}</div>{% endfor %}
|
||||||
|
func init() {
|
||||||
|
// Ignore an already-registered error: multiple inits (tests, plugins
|
||||||
|
// linking this package twice) must not panic.
|
||||||
|
_ = pongo2.RegisterFilter("lines", filterLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterLines(in *pongo2.Value, _ *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||||
|
raw := strings.ReplaceAll(in.String(), "\r\n", "\n")
|
||||||
|
var out []string
|
||||||
|
for line := range strings.SplitSeq(raw, "\n") {
|
||||||
|
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pongo2.AsValue(out), nil
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user