feat: WO-PS-010 SDK HTMLBlock with MediaResolver interface

HTMLBlock uses MediaResolver interface instead of db.Queries for
media metadata enrichment. Includes shared ButtonConfig, MediaValue,
TemplateRenderer interface, and block helper utilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-05-01 09:17:50 +08:00
parent 7c20538a4e
commit 71d3416304
11 changed files with 1090 additions and 5 deletions

View File

@ -1 +0,0 @@
package builtin

299
blocks/builtin/html.go Normal file
View File

@ -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(`<div class="bg-red-50 border border-red-200 text-red-700 p-4 rounded">
<strong>Template Error:</strong> %s
</div>`, 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, `<strong>$1</strong>`)
text = italicRegex.ReplaceAllString(text, `<em>$1</em>`)
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(`<span class="text-%s">%s</span>`, 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, "&amp;", "&")
url = strings.ReplaceAll(url, "&#34;", "\"")
url = strings.ReplaceAll(url, "&#39;", "'")
return fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(url), linkText)
})
return text
}
// WrapLinesInParagraphs converts textarea newlines to <br> tags.
func WrapLinesInParagraphs(text string) string {
return strings.ReplaceAll(text, "\n", "<br>")
}
// 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
}

View File

@ -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{}

View File

@ -1 +0,0 @@
package schemas

250
blocks/shared/button.go Normal file
View File

@ -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 != ""
}

View File

@ -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() != "" {
<a
href={ templ.SafeURL(btn.URL) }
class={ btn.Classes() }
if btn.OpenNewTab {
target="_blank"
rel="noopener noreferrer"
}
style={ btn.InlineStyle() }
>
{ btn.Text }
</a>
} else {
<a
href={ templ.SafeURL(btn.URL) }
class={ btn.Classes() }
if btn.OpenNewTab {
target="_blank"
rel="noopener noreferrer"
}
>
{ btn.Text }
</a>
}
} else {
if btn.InlineStyle() != "" {
<button type="button" class={ btn.Classes() } style={ btn.InlineStyle() }>
{ btn.Text }
</button>
} else {
<button type="button" class={ btn.Classes() }>
{ btn.Text }
</button>
}
}
}
// RenderSubmitButton renders a submit button
templ RenderSubmitButton(btn ButtonConfig) {
if btn.InlineStyle() != "" {
<button type="submit" class={ btn.Classes() } style={ btn.InlineStyle() }>
{ btn.Text }
</button>
} else {
<button type="submit" class={ btn.Classes() }>
{ btn.Text }
</button>
}
}

View File

@ -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, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 8, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if btn.OpenNewTab {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " target=\"_blank\" rel=\"noopener noreferrer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 14, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
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, "</a>")
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, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 20, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if btn.OpenNewTab {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " target=\"_blank\" rel=\"noopener noreferrer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">")
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, "</a>")
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, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 32, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 33, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button>")
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, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 37, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button>")
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, "<button type=\"submit\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 46, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 47, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button>")
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, "<button type=\"submit\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 51, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1 +0,0 @@
package shared

74
blocks/template.go Normal file
View File

@ -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 <img> 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 <img> 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(`<img %s>`, attrs)
}
// RenderWithClass renders the <img> 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(`<img %s>`, 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)
}

9
go.mod
View File

@ -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
)

15
go.sum
View File

@ -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=