diff --git a/blocks/builtin/doc.go b/blocks/builtin/doc.go
deleted file mode 100644
index 5d9ee61..0000000
--- a/blocks/builtin/doc.go
+++ /dev/null
@@ -1 +0,0 @@
-package builtin
diff --git a/blocks/builtin/html.go b/blocks/builtin/html.go
new file mode 100644
index 0000000..bdad86a
--- /dev/null
+++ b/blocks/builtin/html.go
@@ -0,0 +1,299 @@
+package builtin
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "maps"
+ "regexp"
+ "strings"
+
+ "git.dev.alexdunmow.com/ninja/core/blocks"
+ "github.com/google/uuid"
+)
+
+var HTMLBlockMeta = blocks.BlockMeta{
+ Key: "html",
+ Title: "HTML",
+ Description: "Raw HTML content with template variable support",
+ Category: blocks.CategoryContent,
+}
+
+var mediaURLRegex = regexp.MustCompile(`(src|href)=["']media:([^"']+)["']`)
+
+func HTMLBlock(ctx context.Context, content map[string]any) string {
+ var htmlTemplate string
+ if htmlContent, ok := content["_html_content"].(string); ok && htmlContent != "" {
+ htmlTemplate = htmlContent
+ } else {
+ htmlTemplate, _ = content["html"].(string)
+ }
+
+ if htmlTemplate == "" {
+ return ""
+ }
+
+ enrichedContent := enrichMediaFields(ctx, content)
+
+ result, err := blocks.RenderTemplate(htmlTemplate, enrichedContent)
+ if err != nil {
+ if blocks.IsEditor(ctx) {
+ return fmt.Sprintf(`
+ Template Error: %s
+
`, html.EscapeString(err.Error()))
+ }
+ return ""
+ }
+
+ result = blocks.ProcessIcons(result)
+ result = ResolveMediaURLs(result)
+
+ return result
+}
+
+func enrichMediaFields(ctx context.Context, content map[string]any) map[string]any {
+ mediaFields := GetMediaFields(content)
+ if len(mediaFields) == 0 {
+ return content
+ }
+
+ resolver := blocks.GetMediaResolver(ctx)
+
+ enriched := make(map[string]any, len(content))
+ maps.Copy(enriched, content)
+
+ for fieldName := range mediaFields {
+ value, ok := content[fieldName].(string)
+ if !ok || value == "" {
+ continue
+ }
+
+ mediaValue := parseMediaValue(ctx, resolver, value)
+ if mediaValue != nil {
+ enriched[fieldName] = mediaValue
+ }
+ }
+
+ return enriched
+}
+
+func parseMediaValue(ctx context.Context, resolver blocks.MediaResolver, value string) *blocks.MediaValue {
+ if value == "" {
+ return nil
+ }
+
+ path := value
+ if after, ok := strings.CutPrefix(value, "media:"); ok {
+ path = after
+ }
+
+ src := path
+ if !strings.HasPrefix(path, "/media/") {
+ src = "/media/" + path
+ }
+
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) == 0 || parts[0] == "" {
+ return &blocks.MediaValue{Src: src}
+ }
+
+ mediaID, err := uuid.Parse(parts[0])
+ if err != nil {
+ return &blocks.MediaValue{Src: src}
+ }
+
+ if resolver == nil {
+ return &blocks.MediaValue{Src: src}
+ }
+
+ resolved := resolver.ResolveMedia(ctx, mediaID)
+ if resolved == nil {
+ return &blocks.MediaValue{Src: src}
+ }
+
+ resolved.Src = src
+ return resolved
+}
+
+// GetMediaFields extracts field names that have x-editor: "media" from customSchema.
+func GetMediaFields(content map[string]any) map[string]bool {
+ result := make(map[string]bool)
+
+ customSchema, ok := content["customSchema"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ properties, ok := customSchema["properties"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ for key, prop := range properties {
+ propMap, ok := prop.(map[string]any)
+ if !ok {
+ continue
+ }
+ if editor, ok := propMap["x-editor"].(string); ok && editor == "media" {
+ result[key] = true
+ }
+ }
+
+ return result
+}
+
+// ResolveMediaURLs converts media: prefixed URLs to proper /media/ paths.
+func ResolveMediaURLs(htmlStr string) string {
+ return mediaURLRegex.ReplaceAllStringFunc(htmlStr, func(match string) string {
+ submatches := mediaURLRegex.FindStringSubmatch(match)
+ if len(submatches) < 3 {
+ return match
+ }
+ attr := submatches[1]
+ path := submatches[2]
+
+ if strings.HasPrefix(path, "/media/") {
+ return fmt.Sprintf(`%s="%s"`, attr, path)
+ }
+ return fmt.Sprintf(`%s="/media/%s"`, attr, path)
+ })
+}
+
+var (
+ boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ italicRegex = regexp.MustCompile(`\*([^*]+?)\*`)
+ colorRegex = regexp.MustCompile(`==(?:([a-z-]+):)?(.+?)==`)
+ linkRegex = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+)
+
+var validThemeColors = map[string]bool{
+ "foreground": true, "background": true,
+ "primary": true, "primary-foreground": true,
+ "secondary": true, "secondary-foreground": true,
+ "muted": true, "muted-foreground": true,
+ "accent": true, "accent-foreground": true,
+ "destructive": true, "destructive-foreground": true,
+ "border": true, "card": true, "card-foreground": true,
+}
+
+// ProcessMarkdown converts markdown-like syntax to HTML.
+func ProcessMarkdown(text string) string {
+ text = boldRegex.ReplaceAllString(text, `$1`)
+ text = italicRegex.ReplaceAllString(text, `$1`)
+ text = colorRegex.ReplaceAllStringFunc(text, func(match string) string {
+ submatches := colorRegex.FindStringSubmatch(match)
+ if len(submatches) < 3 {
+ return match
+ }
+ colorName := submatches[1]
+ content := submatches[2]
+
+ if colorName == "" || !validThemeColors[colorName] {
+ colorName = "accent"
+ }
+
+ return fmt.Sprintf(`%s`, colorName, content)
+ })
+ text = linkRegex.ReplaceAllStringFunc(text, func(match string) string {
+ submatches := linkRegex.FindStringSubmatch(match)
+ if len(submatches) < 3 {
+ return match
+ }
+ linkText := submatches[1]
+ url := submatches[2]
+ url = strings.ReplaceAll(url, "&", "&")
+ url = strings.ReplaceAll(url, """, "\"")
+ url = strings.ReplaceAll(url, "'", "'")
+ return fmt.Sprintf(`%s`, html.EscapeString(url), linkText)
+ })
+ return text
+}
+
+// WrapLinesInParagraphs converts textarea newlines to
tags.
+func WrapLinesInParagraphs(text string) string {
+ return strings.ReplaceAll(text, "\n", "
")
+}
+
+// GetTextareaFields extracts field names that have x-editor: textarea from customSchema.
+func GetTextareaFields(content map[string]any) map[string]bool {
+ result := make(map[string]bool)
+
+ customSchema, ok := content["customSchema"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ properties, ok := customSchema["properties"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ for key, prop := range properties {
+ propMap, ok := prop.(map[string]any)
+ if !ok {
+ continue
+ }
+ if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
+ result[key] = true
+ }
+ }
+
+ return result
+}
+
+// GetArrayItemTextareaFields extracts textarea field names from an array's item schema.
+func GetArrayItemTextareaFields(content map[string]any, collectionName string) map[string]bool {
+ result := make(map[string]bool)
+
+ customSchema, ok := content["customSchema"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ properties, ok := customSchema["properties"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ arrayField, ok := properties[collectionName].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ if itemProperties, ok := arrayField["itemProperties"].(map[string]any); ok {
+ for _, prop := range itemProperties {
+ propMap, ok := prop.(map[string]any)
+ if !ok {
+ continue
+ }
+ if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
+ if title, ok := propMap["title"].(string); ok && title != "" {
+ result[title] = true
+ }
+ }
+ }
+ return result
+ }
+
+ items, ok := arrayField["items"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ itemProps, ok := items["properties"].(map[string]any)
+ if !ok {
+ return result
+ }
+
+ for key, prop := range itemProps {
+ propMap, ok := prop.(map[string]any)
+ if !ok {
+ continue
+ }
+ if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
+ result[key] = true
+ }
+ }
+
+ return result
+}
diff --git a/blocks/context.go b/blocks/context.go
index b6e05e2..1922ea5 100644
--- a/blocks/context.go
+++ b/blocks/context.go
@@ -313,6 +313,27 @@ func GetHumanProofBanner(ctx context.Context) *HumanProofBannerData {
return nil
}
+// --- Media resolver ---
+
+// MediaResolver resolves media metadata from storage.
+// The CMS implements this with db.Queries.GetMedia.
+type MediaResolver interface {
+ ResolveMedia(ctx context.Context, mediaID uuid.UUID) *MediaValue
+}
+
+type mediaResolverContextKey struct{}
+
+func WithMediaResolver(ctx context.Context, resolver MediaResolver) context.Context {
+ return context.WithValue(ctx, mediaResolverContextKey{}, resolver)
+}
+
+func GetMediaResolver(ctx context.Context) MediaResolver {
+ if r, ok := ctx.Value(mediaResolverContextKey{}).(MediaResolver); ok {
+ return r
+ }
+ return nil
+}
+
// --- Embed resolver ---
type embedResolverContextKey struct{}
diff --git a/blocks/schemas/doc.go b/blocks/schemas/doc.go
deleted file mode 100644
index faeaab6..0000000
--- a/blocks/schemas/doc.go
+++ /dev/null
@@ -1 +0,0 @@
-package schemas
diff --git a/blocks/shared/button.go b/blocks/shared/button.go
new file mode 100644
index 0000000..0b0cffc
--- /dev/null
+++ b/blocks/shared/button.go
@@ -0,0 +1,250 @@
+package shared
+
+import (
+ "strconv"
+ "strings"
+)
+
+// ButtonConfig represents a unified button configuration
+type ButtonConfig struct {
+ // Content
+ Text string
+ URL string
+
+ // Type determines theme defaults
+ // Built-in: "primary" | "secondary" | "outline" | "ghost" | "link" | "destructive"
+ // Custom: any user-defined type name from theme.buttons.custom
+ Type string
+
+ // Override Mode
+ UseThemeDefaults bool // When true, uses CSS variables from theme
+
+ // Custom Overrides (only used when UseThemeDefaults = false)
+ TextColor string // Tailwind class like "text-white" or "text-[#ff5500]"
+ TextOpacity int // 0-100
+ BgColor string // Tailwind class like "bg-primary" or "bg-[#ff5500]"
+ BgOpacity int // 0-100
+
+ // Effects (can override theme)
+ Pill bool // Fully rounded corners
+ Elevated bool // Shadow
+ ThreeDee bool // 3D push effect
+ Size string // "sm" | "md" | "lg"
+
+ // Behavior
+ OpenNewTab bool
+}
+
+// Classes returns CSS classes for the button
+func (b ButtonConfig) Classes() string {
+ var classes []string
+ classes = append(classes, "btn")
+
+ // Size class
+ switch b.Size {
+ case "sm":
+ classes = append(classes, "btn-sm")
+ case "lg":
+ classes = append(classes, "btn-lg")
+ default:
+ classes = append(classes, "btn-md")
+ }
+
+ if b.UseThemeDefaults {
+ // Use theme CSS variable classes
+ btnType := b.Type
+ if btnType == "" {
+ btnType = "primary"
+ }
+ classes = append(classes, "btn-"+btnType)
+ } else {
+ // Use custom Tailwind classes
+ if b.BgColor != "" {
+ classes = append(classes, b.BgColor)
+ } else {
+ // Default based on type variant
+ switch b.Type {
+ case "outline":
+ classes = append(classes, "border-2", "border-primary", "bg-transparent")
+ case "ghost":
+ classes = append(classes, "bg-transparent", "hover:bg-accent")
+ case "link":
+ classes = append(classes, "bg-transparent", "underline", "underline-offset-4")
+ case "destructive":
+ classes = append(classes, "bg-destructive", "hover:bg-destructive/90")
+ default: // primary, secondary, solid
+ classes = append(classes, "bg-primary", "hover:bg-primary/90")
+ }
+ }
+
+ if b.TextColor != "" {
+ classes = append(classes, b.TextColor)
+ } else {
+ switch b.Type {
+ case "outline":
+ classes = append(classes, "text-primary")
+ case "ghost":
+ classes = append(classes, "text-foreground", "hover:text-accent-foreground")
+ case "link":
+ classes = append(classes, "text-primary")
+ case "destructive":
+ classes = append(classes, "text-destructive-foreground")
+ default:
+ classes = append(classes, "text-primary-foreground")
+ }
+ }
+ }
+
+ // Effects that can override theme
+ if b.Pill {
+ classes = append(classes, "rounded-full")
+ }
+
+ if b.Elevated && b.Type != "ghost" && b.Type != "link" {
+ classes = append(classes, "shadow-lg", "hover:shadow-xl")
+ }
+
+ if b.ThreeDee && (b.Type == "" || b.Type == "primary" || b.Type == "solid") {
+ classes = append(classes, "border-b-4", "border-primary/70", "active:border-b-2", "active:mt-0.5")
+ }
+
+ return strings.Join(classes, " ")
+}
+
+// InlineStyle returns inline CSS styles for opacity if needed
+func (b ButtonConfig) InlineStyle() string {
+ var styles []string
+
+ if !b.UseThemeDefaults {
+ if b.TextOpacity > 0 && b.TextOpacity < 100 {
+ opacity := float64(b.TextOpacity) / 100.0
+ styles = append(styles, "color-opacity: "+strconv.FormatFloat(opacity, 'f', 2, 64))
+ }
+ if b.BgOpacity > 0 && b.BgOpacity < 100 {
+ opacity := float64(b.BgOpacity) / 100.0
+ styles = append(styles, "--tw-bg-opacity: "+strconv.FormatFloat(opacity, 'f', 2, 64))
+ }
+ }
+
+ if len(styles) == 0 {
+ return ""
+ }
+ return strings.Join(styles, "; ")
+}
+
+// ParseButton extracts ButtonConfig from JSON content
+func ParseButton(value any, defaultType string) ButtonConfig {
+ btn := ButtonConfig{
+ Type: defaultType,
+ UseThemeDefaults: true, // Default to theme styling
+ TextOpacity: 100,
+ BgOpacity: 100,
+ Size: "md",
+ }
+
+ if value == nil {
+ return btn
+ }
+
+ obj, ok := value.(map[string]any)
+ if !ok {
+ return btn
+ }
+
+ // Content
+ if text, ok := obj["text"].(string); ok {
+ btn.Text = text
+ }
+ if url, ok := obj["url"].(string); ok {
+ btn.URL = url
+ }
+
+ // Type - check explicit type field first
+ typeExplicitlySet := false
+ if t, ok := obj["type"].(string); ok && t != "" {
+ btn.Type = t
+ typeExplicitlySet = true
+ }
+
+ // Use theme defaults toggle - explicit setting takes precedence
+ if useTheme, ok := obj["useThemeDefaults"].(bool); ok {
+ btn.UseThemeDefaults = useTheme
+ } else {
+ // Legacy: if useThemeDefaults not set but custom colors exist, assume not using theme defaults
+ if textColor, ok := obj["textColor"].(string); ok && textColor != "" {
+ btn.UseThemeDefaults = false
+ } else if bgColor, ok := obj["bgColor"].(string); ok && bgColor != "" {
+ btn.UseThemeDefaults = false
+ }
+ }
+
+ // Custom overrides
+ if textColor, ok := obj["textColor"].(string); ok {
+ btn.TextColor = textColor
+ }
+ if textOpacity, ok := obj["textOpacity"].(float64); ok {
+ btn.TextOpacity = int(textOpacity)
+ }
+ if bgColor, ok := obj["bgColor"].(string); ok {
+ btn.BgColor = bgColor
+ }
+ if bgOpacity, ok := obj["bgOpacity"].(float64); ok {
+ btn.BgOpacity = int(bgOpacity)
+ }
+
+ // Effects
+ if pill, ok := obj["pill"].(bool); ok {
+ btn.Pill = pill
+ }
+ if elevated, ok := obj["elevated"].(bool); ok {
+ btn.Elevated = elevated
+ }
+ if threeDee, ok := obj["threeDee"].(bool); ok {
+ btn.ThreeDee = threeDee
+ }
+ if size, ok := obj["size"].(string); ok && size != "" {
+ btn.Size = size
+ }
+
+ // Behavior
+ if openNewTab, ok := obj["openNewTab"].(bool); ok {
+ btn.OpenNewTab = openNewTab
+ }
+
+ // Legacy support: "style" field from old format
+ if style, ok := obj["style"].(string); ok && style != "" && btn.Type == defaultType {
+ // Map legacy styles to types
+ switch style {
+ case "secondary":
+ btn.Type = "secondary"
+ case "outline":
+ btn.Type = "outline"
+ case "ghost":
+ btn.Type = "ghost"
+ case "link":
+ btn.Type = "link"
+ case "destructive":
+ btn.Type = "destructive"
+ default:
+ btn.Type = "primary"
+ }
+ }
+
+ // Legacy: "variant" field - only use if type wasn't explicitly set
+ if !typeExplicitlySet {
+ if variant, ok := obj["variant"].(string); ok && variant != "" {
+ if variant == "solid" {
+ btn.Type = "primary"
+ } else {
+ btn.Type = variant
+ }
+ }
+ }
+
+ return btn
+}
+
+// HasContent returns true if the button has text and URL
+func (b ButtonConfig) HasContent() bool {
+ return b.Text != "" && b.URL != ""
+}
diff --git a/blocks/shared/button.templ b/blocks/shared/button.templ
new file mode 100644
index 0000000..70f95f8
--- /dev/null
+++ b/blocks/shared/button.templ
@@ -0,0 +1,54 @@
+package shared
+
+// RenderButton renders a button or link based on ButtonConfig
+templ RenderButton(btn ButtonConfig) {
+ if btn.URL != "" {
+ if btn.InlineStyle() != "" {
+
+ { btn.Text }
+
+ } else {
+
+ { btn.Text }
+
+ }
+ } else {
+ if btn.InlineStyle() != "" {
+
+ } else {
+
+ }
+ }
+}
+
+// RenderSubmitButton renders a submit button
+templ RenderSubmitButton(btn ButtonConfig) {
+ if btn.InlineStyle() != "" {
+
+ } else {
+
+ }
+}
diff --git a/blocks/shared/button_templ.go b/blocks/shared/button_templ.go
new file mode 100644
index 0000000..672fbf7
--- /dev/null
+++ b/blocks/shared/button_templ.go
@@ -0,0 +1,370 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1001
+package shared
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+// RenderButton renders a button or link based on ButtonConfig
+func RenderButton(btn ButtonConfig) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if btn.URL != "" {
+ if btn.InlineStyle() != "" {
+ var templ_7745c5c3_Var2 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 16, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var7 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 27, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ } else {
+ if btn.InlineStyle() != "" {
+ var templ_7745c5c3_Var11 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var15 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ return nil
+ })
+}
+
+// RenderSubmitButton renders a submit button
+func RenderSubmitButton(btn ButtonConfig) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var18 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var18 == nil {
+ templ_7745c5c3_Var18 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if btn.InlineStyle() != "" {
+ var templ_7745c5c3_Var19 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var23 = []any{btn.Classes()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/blocks/shared/doc.go b/blocks/shared/doc.go
deleted file mode 100644
index a29b5e4..0000000
--- a/blocks/shared/doc.go
+++ /dev/null
@@ -1 +0,0 @@
-package shared
diff --git a/blocks/template.go b/blocks/template.go
new file mode 100644
index 0000000..0338201
--- /dev/null
+++ b/blocks/template.go
@@ -0,0 +1,74 @@
+package blocks
+
+import (
+ "fmt"
+ "html"
+)
+
+// MediaValue represents a rich media object for template substitution.
+// Implements fmt.Stringer to render a full
tag when used directly.
+type MediaValue struct {
+ Src string // Full URL path (e.g., "/media/uuid/webp.webp")
+ Alt string // Alt text from media library
+ Filename string // Original filename
+ Width int // Image width in pixels
+ Height int // Image height in pixels
+}
+
+// String renders the default
tag representation.
+func (m MediaValue) String() string {
+ attrs := fmt.Sprintf(`src="%s" alt="%s" class="w-full h-auto"`,
+ html.EscapeString(m.Src),
+ html.EscapeString(m.Alt))
+ if m.Width > 0 && m.Height > 0 {
+ attrs += fmt.Sprintf(` width="%d" height="%d"`, m.Width, m.Height)
+ }
+ return fmt.Sprintf(`
`, attrs)
+}
+
+// RenderWithClass renders the
tag with custom CSS classes.
+func (m MediaValue) RenderWithClass(class string) string {
+ attrs := fmt.Sprintf(`src="%s" alt="%s" class="%s"`,
+ html.EscapeString(m.Src),
+ html.EscapeString(m.Alt),
+ html.EscapeString(class))
+ if m.Width > 0 && m.Height > 0 {
+ attrs += fmt.Sprintf(` width="%d" height="%d"`, m.Width, m.Height)
+ }
+ return fmt.Sprintf(`
`, attrs)
+}
+
+// TemplateRenderer is the interface for rendering pongo2/Jinja2-style templates.
+// The CMS registers an implementation at startup.
+type TemplateRenderer interface {
+ RenderTemplate(tmpl string, data map[string]any) (string, error)
+ ProcessIcons(html string) string
+}
+
+// templateRenderer holds the registered template renderer.
+var templateRenderer TemplateRenderer
+
+// RegisterTemplateRenderer sets the global template renderer.
+// Called by the CMS at startup to provide pongo2 rendering.
+func RegisterTemplateRenderer(r TemplateRenderer) {
+ templateRenderer = r
+}
+
+// RenderTemplate renders a pongo2/Jinja2-style template string with the given data.
+// Returns the rendered HTML or an error. Falls back to a no-op if no renderer is registered.
+func RenderTemplate(tmpl string, data map[string]any) (string, error) {
+ if templateRenderer == nil {
+ // No renderer registered — return template as-is (no variable substitution)
+ return tmpl, nil
+ }
+ return templateRenderer.RenderTemplate(tmpl, data)
+}
+
+// ProcessIcons processes ::pack:name:: icon syntax in HTML.
+// Returns the HTML with icons replaced, or the original if no renderer is registered.
+func ProcessIcons(htmlStr string) string {
+ if templateRenderer == nil {
+ return htmlStr
+ }
+ return templateRenderer.ProcessIcons(htmlStr)
+}
diff --git a/go.mod b/go.mod
index 4c53053..a7ec556 100644
--- a/go.mod
+++ b/go.mod
@@ -7,12 +7,17 @@ require (
github.com/a-h/templ v0.3.1001
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
- golang.org/x/mod v0.27.0
+ golang.org/x/mod v0.34.0
)
require (
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- golang.org/x/text v0.29.0 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
+ golang.org/x/net v0.42.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
diff --git a/go.sum b/go.sum
index 969a6da..1b0fcfd 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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=
@@ -9,6 +11,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -17,6 +21,8 @@ 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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -24,12 +30,21 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=