diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ada19f1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# Core SDK + +Go module `git.dev.alexdunmow.com/block/core`. Defines plugin interfaces, template engine, block registry, and shared types for the BlockNinja CMS. + +## Critical Rules + +- **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version. +- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator. +- All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers. diff --git a/go.mod b/go.mod index f2db536..00c8ae6 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( connectrpc.com/connect v1.20.0 github.com/BurntSushi/toml v1.6.0 github.com/a-h/templ v0.3.1001 + github.com/flosch/pongo2/v6 v6.1.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/spf13/cobra v1.10.2 @@ -17,6 +18,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index ae03f99..a6ea0d8 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ= +github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -22,8 +24,14 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -44,6 +52,8 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/templates/pongo/base.html b/templates/pongo/base.html new file mode 100644 index 0000000..524d4c6 --- /dev/null +++ b/templates/pongo/base.html @@ -0,0 +1,9 @@ + + +{{ head_html|safe }} + +{{ admin_banner_html|safe }} +{% block body %}{% endblock %} +{{ body_end_html|safe }} + + diff --git a/templates/pongo/context.go b/templates/pongo/context.go new file mode 100644 index 0000000..377ea7e --- /dev/null +++ b/templates/pongo/context.go @@ -0,0 +1,101 @@ +package pongo + +import ( + "context" + + "github.com/flosch/pongo2/v6" + + "git.dev.alexdunmow.com/block/core/blocks" + "git.dev.alexdunmow.com/block/core/templates/bn" +) + +// buildPageContext builds a pongo2.Context with pre-rendered head/body HTML +// and all page-level variables from the standard doc map. +func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo2.Context { + title := "Untitled" + if t, ok := doc["title"].(string); ok && t != "" { + title = t + } + + slots := make(map[string]string) + if s, ok := doc["slots"].(map[string]string); ok { + slots = s + } + + themeMode := "dark" + if tm, ok := doc["theme_mode"].(string); ok && tm != "" { + themeMode = tm + } + + themeCSS := "" + if tc, ok := doc["theme_css"].(string); ok { + themeCSS = tc + } + + structuredData := "" + if sd, ok := doc["structured_data"].(string); ok { + structuredData = sd + } + + cssHash := "" + if ch, ok := doc["css_hash"].(string); ok { + cssHash = ch + } + + pageviewNonce := "" + if pn, ok := doc["pageview_nonce"].(string); ok { + pageviewNonce = pn + } + + settings := bn.ParseSiteSettings(doc) + pageMeta := bn.ParsePageMeta(doc) + engagementConfig := bn.ParseEngagementConfig(doc) + + headHTML := renderComponent(ctx, bn.Head(bn.HeadData{ + Title: title, + Settings: settings, + PageMeta: pageMeta, + ThemeMode: themeMode, + ThemeCSS: themeCSS, + PluginStyles: e.stylePaths, + StructuredData: structuredData, + CSSHash: cssHash, + PageviewNonce: pageviewNonce, + EngagementConfig: engagementConfig, + })) + bodyEndHTML := renderComponent(ctx, bn.BodyEnd(settings)) + bannerHTML := renderComponent(ctx, bn.AdminBypassBanner(settings)) + + slotsAny := make(map[string]any, len(slots)) + for k, v := range slots { + slotsAny[k] = v + } + + return pongo2.Context{ + "head_html": headHTML, + "body_end_html": bodyEndHTML, + "admin_banner_html": bannerHTML, + + "title": title, + "slots": slotsAny, + "theme_mode": themeMode, + "theme_css": themeCSS, + "css_hash": cssHash, + + "site_settings": settings, + "page_meta": pageMeta, + } +} + +// buildBlockContext builds a pongo2.Context for block rendering. +// Content fields are available directly; request context is under "ctx". +func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context { + pongoCtx := make(pongo2.Context, len(content)+1) + for k, v := range content { + pongoCtx[k] = v + } + if bc := blocks.GetBlockContext(ctx); bc != nil { + pongoCtx["ctx"] = bc.ToMap() + } + return pongoCtx +} diff --git a/templates/pongo/embed.go b/templates/pongo/embed.go new file mode 100644 index 0000000..0067d29 --- /dev/null +++ b/templates/pongo/embed.go @@ -0,0 +1,6 @@ +package pongo + +import "embed" + +//go:embed base.html +var baseFS embed.FS diff --git a/templates/pongo/engine.go b/templates/pongo/engine.go new file mode 100644 index 0000000..dbcb9d9 --- /dev/null +++ b/templates/pongo/engine.go @@ -0,0 +1,172 @@ +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 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 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() +} diff --git a/templates/pongo/loader.go b/templates/pongo/loader.go new file mode 100644 index 0000000..dce29d8 --- /dev/null +++ b/templates/pongo/loader.go @@ -0,0 +1,43 @@ +package pongo + +import ( + "fmt" + "io" + "io/fs" + + "github.com/flosch/pongo2/v6" +) + +// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface. +type fsLoader struct { + fsys fs.FS +} + +func (l *fsLoader) Abs(base, name string) string { + return name +} + +func (l *fsLoader) Get(path string) (io.Reader, error) { + return l.fsys.Open(path) +} + +// multiLoader chains multiple loaders, returning the first hit. +// Plugin templates can {% extends "base.html" %} where base.html +// lives in the core embedded FS rather than the plugin FS. +type multiLoader struct { + loaders []pongo2.TemplateLoader +} + +func (l *multiLoader) Abs(base, name string) string { + return name +} + +func (l *multiLoader) Get(path string) (io.Reader, error) { + for _, loader := range l.loaders { + r, err := loader.Get(path) + if err == nil { + return r, nil + } + } + return nil, fmt.Errorf("pongo: template %q not found in any loader", path) +} diff --git a/templates/types.go b/templates/types.go index 3e66e82..62d2471 100644 --- a/templates/types.go +++ b/templates/types.go @@ -2,12 +2,17 @@ package templates import ( "context" - - "github.com/a-h/templ" + "io" ) +// HTMLComponent is the generic interface for rendered template output. +// Both templ.Component and pongo2 engine output satisfy this interface. +type HTMLComponent interface { + Render(ctx context.Context, w io.Writer) error +} + // TemplateFunc is the signature for template functions loaded from plugins. -type TemplateFunc func(ctx context.Context, doc map[string]any) templ.Component +type TemplateFunc func(ctx context.Context, doc map[string]any) HTMLComponent // PageTemplateMeta provides metadata about a page template within a system template. type PageTemplateMeta struct {