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=