core/blocks/builtin/html.go
Alex Dunmow 13d741979a fix: rename module path from ninja/core to block/core
Matches the actual Gitea repo location at block/core.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 09:39:10 +08:00

300 lines
7.2 KiB
Go

package builtin
import (
"context"
"fmt"
"html"
"maps"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/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
}