From 7eb3e27053b3eb6dfc1d7242853d00d7b6cda645 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sun, 3 May 2026 10:18:32 +0800 Subject: [PATCH] feat: converge BlockNote renderer, add datasources bridge, rename ServiceDeps to CoreServices SDK renderer now has full feature parity with the host: text alignment, checkListItem, toggleListItem, video, audio, file, statement blocks, and text/background color inline styles. New datasources.Datasources interface lets plugins resolve buckets directly. Co-Authored-By: Claude Opus 4.6 --- content/content.go | 2 +- datasources/datasources.go | 21 ++ plugin/deps.go | 6 +- plugin/registration.go | 8 +- render/blocknote.go | 213 ++++++++++++- render/blocknote_test.go | 592 +++++++++++++++++++++++++++++++++++++ 6 files changed, 829 insertions(+), 13 deletions(-) create mode 100644 datasources/datasources.go diff --git a/content/content.go b/content/content.go index 5c6b472..a83cc9e 100644 --- a/content/content.go +++ b/content/content.go @@ -35,7 +35,7 @@ type PostInfo struct { } // Content provides content access for plugins. -// The CMS implements this interface and wires it into ServiceDeps. +// The CMS implements this interface and wires it into CoreServices. type Content interface { GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error) GetPage(ctx context.Context, slug string) (*PageInfo, error) diff --git a/datasources/datasources.go b/datasources/datasources.go new file mode 100644 index 0000000..8bbd5e7 --- /dev/null +++ b/datasources/datasources.go @@ -0,0 +1,21 @@ +package datasources + +import ( + "context" + + "github.com/google/uuid" +) + +// Datasources provides bucket/datasource access for plugins. +// The CMS implements this interface and wires it into CoreServices. +type Datasources interface { + ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*Result, error) + ResolveBucketByKey(ctx context.Context, bucketKey string) (*Result, error) +} + +// Result is the output from resolving a bucket. +type Result struct { + Items []any `json:"items"` + Total int `json:"total"` + Meta map[string]any `json:"meta,omitempty"` +} diff --git a/plugin/deps.go b/plugin/deps.go index 594374f..6a5478c 100644 --- a/plugin/deps.go +++ b/plugin/deps.go @@ -8,6 +8,7 @@ import ( "git.dev.alexdunmow.com/block/core/auth" "git.dev.alexdunmow.com/block/core/content" "git.dev.alexdunmow.com/block/core/crypto" + "git.dev.alexdunmow.com/block/core/datasources" "git.dev.alexdunmow.com/block/core/gating" "git.dev.alexdunmow.com/block/core/menus" "git.dev.alexdunmow.com/block/core/settings" @@ -15,14 +16,15 @@ import ( "github.com/google/uuid" ) -// ServiceDeps provides dependencies that plugins need for RPC service handlers. -type ServiceDeps struct { +// CoreServices provides CMS capabilities to plugins. +type CoreServices struct { // Capability interfaces — typed access to CMS functionality Content content.Content Settings settings.Settings Gating gating.Gating Crypto crypto.Crypto Menus menus.Menus + Datasources datasources.Datasources PublicUsers auth.PublicUsers Subscriptions subscriptions.Subscriptions diff --git a/plugin/registration.go b/plugin/registration.go index cb8cf02..7935804 100644 --- a/plugin/registration.go +++ b/plugin/registration.go @@ -27,19 +27,19 @@ type PluginRegistration struct { BundledFonts func() []byte MasterPages func() []MasterPageDefinition - HTTPHandler func(deps ServiceDeps) http.Handler + HTTPHandler func(deps CoreServices) http.Handler SettingsPanel func() string AdminPages func() []AdminPage CSSManifest func() *CSSManifest - ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error) - JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc + ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error) + JobHandlers func(deps CoreServices) map[string]JobHandlerFunc AIActions func() []AIAction DirectoryExtensions func() *DirectoryExtensions MediaHooks MediaHooksProvider - Load func(deps ServiceDeps) error + Load func(deps CoreServices) error Unload func(ctx context.Context) error Dependencies []Dependency diff --git a/render/blocknote.go b/render/blocknote.go index 1828da3..62e5002 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -13,11 +13,11 @@ import ( // BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML. func BlockNoteToHTML(ctx context.Context, doc map[string]any) string { - blocks := blocksFromRaw(doc["blocks"]) - if len(blocks) == 0 { + rawBlocks := blocksFromRaw(doc["blocks"]) + if len(rawBlocks) == 0 { return "" } - return renderBlocks(ctx, blocks) + return renderBlocks(ctx, rawBlocks) } func renderBlocks(ctx context.Context, blocks []map[string]any) string { @@ -110,6 +110,75 @@ func blocksFromRaw(raw any) []map[string]any { } } +func textAlignClass(props map[string]any) string { + if props == nil { + return "" + } + align, _ := props["textAlignment"].(string) + switch align { + case "left": + return "text-left" + case "center": + return "text-center" + case "right": + return "text-right" + case "justify": + return "text-justify" + default: + return "" + } +} + +func safeClassToken(value string) string { + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return "" + } + return value +} + +func isHexDigit(c rune) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +func colorValueToClass(value string, prefix string) string { + if value == "" || value == "default" { + return "" + } + + if after, ok := strings.CutPrefix(value, "hex:"); ok { + hex := after + if len(hex) != 7 && len(hex) != 4 { + return "" + } + for i, r := range hex { + if i == 0 && r == '#' { + continue + } + if !isHexDigit(r) { + return "" + } + } + return fmt.Sprintf("%s-[%s]", prefix, hex) + } + + if after, ok := strings.CutPrefix(value, "custom:"); ok { + name := safeClassToken(after) + if name == "" { + return "" + } + return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name) + } + + value = safeClassToken(value) + if value == "" { + return "" + } + return fmt.Sprintf("%s-%s", prefix, value) +} + func renderBlock(ctx context.Context, block map[string]any) string { blockType, _ := block["type"].(string) props, _ := block["props"].(map[string]any) @@ -120,7 +189,11 @@ func renderBlock(ctx context.Context, block map[string]any) string { switch blockType { case "paragraph": - sb.WriteString("

") + classNames := "my-4" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "

", classNames) sb.WriteString(renderInlineContent(content)) if childrenHTML != "" { sb.WriteString(childrenHTML) @@ -140,7 +213,11 @@ func renderBlock(ctx context.Context, block map[string]any) string { if level > 6 { level = 6 } - fmt.Fprintf(&sb, "", level) + classNames := "mt-8 mb-4 font-bold" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "", level, classNames) sb.WriteString(renderInlineContent(content)) if childrenHTML != "" { sb.WriteString(childrenHTML) @@ -148,13 +225,48 @@ func renderBlock(ctx context.Context, block map[string]any) string { fmt.Fprintf(&sb, "\n", level) case "quote": - sb.WriteString("

") + classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "
", classNames) sb.WriteString(renderInlineContent(content)) if childrenHTML != "" { sb.WriteString(childrenHTML) } sb.WriteString("
\n") + case "checkListItem": + checked := false + if c, ok := props["checked"].(bool); ok { + checked = c + } + checkedAttr := "" + if checked { + checkedAttr = " checked" + } + fmt.Fprintf(&sb, `
`, checkedAttr) + sb.WriteString(renderInlineContent(content)) + sb.WriteString("") + if childrenHTML != "" { + fmt.Fprintf(&sb, `
%s
`, childrenHTML) + } + sb.WriteString("
\n") + + case "toggleListItem": + openAttr := "" + if open, ok := props["open"].(bool); ok && open { + openAttr = " open" + } + fmt.Fprintf(&sb, `
`, openAttr) + sb.WriteString(``) + sb.WriteString(renderInlineContent(content)) + sb.WriteString("") + if childrenHTML != "" { + fmt.Fprintf(&sb, `
%s
`, childrenHTML) + } + sb.WriteString("
\n") + case "codeBlock": lang := "" if l, ok := props["language"].(string); ok { @@ -187,6 +299,67 @@ func renderBlock(ctx context.Context, block map[string]any) string { } sb.WriteString("\n") + case "video": + url := "" + caption := "" + if u, ok := props["url"].(string); ok { + url = u + } + if c, ok := props["caption"].(string); ok { + caption = c + } + sb.WriteString(`
`) + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + if caption != "" { + fmt.Fprintf(&sb, "
%s
", html.EscapeString(caption)) + } + sb.WriteString("
\n") + + case "audio": + url := "" + caption := "" + if u, ok := props["url"].(string); ok { + url = u + } + if c, ok := props["caption"].(string); ok { + caption = c + } + sb.WriteString(`
`) + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + if caption != "" { + fmt.Fprintf(&sb, "
%s
", html.EscapeString(caption)) + } + sb.WriteString("
\n") + + case "file": + url := "" + name := "" + caption := "" + if u, ok := props["url"].(string); ok { + url = u + } + if n, ok := props["name"].(string); ok { + name = n + } + if c, ok := props["caption"].(string); ok { + caption = c + } + if name == "" { + name = url + } + sb.WriteString(`
`) + if url != "" { + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + sb.WriteString(html.EscapeString(name)) + sb.WriteString("") + } else { + sb.WriteString(html.EscapeString(name)) + } + if caption != "" { + fmt.Fprintf(&sb, `

%s

`, html.EscapeString(caption)) + } + sb.WriteString("
\n") + case "table": sb.WriteString(`
`) if tableContent, ok := block["content"].(map[string]any); ok { @@ -245,6 +418,18 @@ func renderBlock(ctx context.Context, block map[string]any) string { } } + case "statement": + sb.WriteString("
\n") + if len(content) > 0 { + sb.WriteString("

") + sb.WriteString(renderInlineContent(content)) + sb.WriteString("

\n") + } + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
\n") + case "humanProof": if hp := blocks.GetHumanProofBanner(ctx); hp != nil { sb.WriteString(blocks.RenderHumanProofBanner(hp)) @@ -325,6 +510,22 @@ func renderInlineContent(content []map[string]any) string { if code, ok := styles["code"].(bool); ok && code { rendered = "" + rendered + "" } + + var colorClasses []string + if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" { + if class := colorValueToClass(textColor, "text"); class != "" { + colorClasses = append(colorClasses, class) + } + } + if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" { + if class := colorValueToClass(bgColor, "bg"); class != "" { + colorClasses = append(colorClasses, class) + } + } + + if len(colorClasses) > 0 { + rendered = fmt.Sprintf(`%s`, strings.Join(colorClasses, " "), rendered) + } } sb.WriteString(rendered) diff --git a/render/blocknote_test.go b/render/blocknote_test.go index 5ffdf29..97c32bb 100644 --- a/render/blocknote_test.go +++ b/render/blocknote_test.go @@ -46,3 +46,595 @@ func TestBlockNoteToHTMLUsesSDKContextResolvers(t *testing.T) { t.Fatalf("BlockNoteToHTML() did not render human proof banner from SDK context: %s", html) } } + +func TestTextAlignment(t *testing.T) { + tests := []struct { + name string + blockType string + alignment string + wantClass string + }{ + {"paragraph center", "paragraph", "center", "text-center"}, + {"paragraph right", "paragraph", "right", "text-right"}, + {"heading center", "heading", "center", "text-center"}, + {"quote justify", "quote", "justify", "text-justify"}, + {"paragraph left", "paragraph", "left", "text-left"}, + {"paragraph default", "paragraph", "", "my-4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + props := map[string]any{} + if tt.alignment != "" { + props["textAlignment"] = tt.alignment + } + if tt.blockType == "heading" { + props["level"] = float64(2) + } + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": tt.blockType, + "props": props, + "content": "Test", + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, tt.wantClass) { + t.Errorf("expected class %q in output: %s", tt.wantClass, html) + } + }) + } +} + +func TestCheckListItem(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "checkListItem", + "props": map[string]any{"checked": true}, + "content": "Done task", + }, + map[string]any{ + "type": "checkListItem", + "props": map[string]any{"checked": false}, + "content": "Todo task", + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + + if !strings.Contains(html, `checked`) { + t.Errorf("expected checked attribute: %s", html) + } + if !strings.Contains(html, "Done task") || !strings.Contains(html, "Todo task") { + t.Errorf("expected checklist content: %s", html) + } + if !strings.Contains(html, `type="checkbox"`) { + t.Errorf("expected checkbox input: %s", html) + } +} + +func TestToggleListItem(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "toggleListItem", + "content": "Details", + "children": []any{ + map[string]any{ + "type": "paragraph", + "content": "Nested content", + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + + if !strings.Contains(html, "Bold text") { + t.Errorf("expected bold formatting: %s", html) + } +} + +func TestColorValueToClass(t *testing.T) { + tests := []struct { + name string + input string + prefix string + expected string + }{ + {"empty", "", "text", ""}, + {"default", "default", "text", ""}, + {"theme primary text", "primary", "text", "text-primary"}, + {"theme primary bg", "primary", "bg", "bg-primary"}, + {"theme foreground", "foreground", "text", "text-foreground"}, + {"theme muted-foreground", "muted-foreground", "text", "text-muted-foreground"}, + {"custom color text", "custom:brand", "text", "text-[hsl(var(--color-brand))]"}, + {"custom color bg", "custom:brand", "bg", "bg-[hsl(var(--color-brand))]"}, + {"hex color text", "hex:#ff5500", "text", "text-[#ff5500]"}, + {"hex color bg", "hex:#ff5500", "bg", "bg-[#ff5500]"}, + {"short hex", "hex:#f00", "text", "text-[#f00]"}, + {"invalid hex length", "hex:#ff", "text", ""}, + {"invalid hex char", "hex:#gggggg", "text", ""}, + {"custom with invalid chars", "custom:bad name", "text", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorValueToClass(tt.input, tt.prefix) + if got != tt.expected { + t.Errorf("colorValueToClass(%q, %q) = %q, want %q", tt.input, tt.prefix, got, tt.expected) + } + }) + } +} + +func TestRenderInlineContentWithTextColor(t *testing.T) { + content := []map[string]any{ + { + "type": "text", + "text": "Hello", + "styles": map[string]any{ + "textColor": "primary", + }, + }, + } + html := renderInlineContent(content) + + if !strings.Contains(html, `class="text-primary"`) { + t.Errorf("expected text color class: %s", html) + } +} + +func TestRenderInlineContentWithBackgroundColor(t *testing.T) { + content := []map[string]any{ + { + "type": "text", + "text": "Highlighted", + "styles": map[string]any{ + "backgroundColor": "hex:#ffcc00", + }, + }, + } + html := renderInlineContent(content) + + if !strings.Contains(html, `bg-[#ffcc00]`) { + t.Errorf("expected background color class: %s", html) + } +} + +func TestRenderInlineContentWithBothColors(t *testing.T) { + content := []map[string]any{ + { + "type": "text", + "text": "Styled", + "styles": map[string]any{ + "textColor": "foreground", + "backgroundColor": "custom:highlight", + }, + }, + } + html := renderInlineContent(content) + + if !strings.Contains(html, `text-foreground`) { + t.Errorf("expected text color class: %s", html) + } + if !strings.Contains(html, `bg-[hsl(var(--color-highlight))]`) { + t.Errorf("expected background color class: %s", html) + } +} + +func TestRenderInlineContentWithDefaultColor(t *testing.T) { + content := []map[string]any{ + { + "type": "text", + "text": "Normal", + "styles": map[string]any{ + "textColor": "default", + }, + }, + } + html := renderInlineContent(content) + + if strings.Contains(html, "class=") { + t.Errorf("default color should not add class: %s", html) + } +} + +func TestRenderInlineContentWithColorsAndOtherStyles(t *testing.T) { + content := []map[string]any{ + { + "type": "text", + "text": "Bold and colored", + "styles": map[string]any{ + "bold": true, + "textColor": "hex:#ff0000", + }, + }, + } + html := renderInlineContent(content) + + if !strings.Contains(html, "") { + t.Errorf("expected bold tag: %s", html) + } + if !strings.Contains(html, `text-[#ff0000]`) { + t.Errorf("expected text color class: %s", html) + } +} + +func TestBlockNoteToHTMLWithColoredParagraph(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "Red text", + "styles": map[string]any{ + "textColor": "hex:#ff0000", + }, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + + if !strings.Contains(html, "") { + t.Errorf("expected thead: %s", html) + } + if strings.Contains(html, "") { + t.Errorf("did not expect tbody for single-row table: %s", html) + } +} + +func TestTableCellStringContent(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "table", + "content": map[string]any{ + "rows": []any{ + map[string]any{"cells": []any{"Header", "Value"}}, + map[string]any{"cells": []any{"Row", "Cell"}}, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, "Header") || !strings.Contains(html, "Cell") { + t.Errorf("expected table cell content: %s", html) + } + if !strings.Contains(html, "") { + t.Errorf("expected tbody for multi-row table: %s", html) + } +} + +func TestNestedListRendering(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "bulletListItem", + "content": "Parent", + "children": []any{ + map[string]any{ + "type": "bulletListItem", + "content": "Child", + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, "Parent") || !strings.Contains(html, "Child") { + t.Errorf("expected nested list content: %s", html) + } + if strings.Count(html, "") { + t.Errorf("expected hard break in output: %s", html) + } +} + +func TestReferencesBlock(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "references", + "props": map[string]any{ + "items": `[{"text":"Steve Blank","url":"https://example.com/blank"},{"text":"Charity Majors","url":""}]`, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + + if !strings.Contains(html, `class="bn-references"`) { + t.Errorf("expected bn-references class: %s", html) + } + if !strings.Contains(html, `Steve Blank`) { + t.Errorf("expected linked reference: %s", html) + } + if !strings.Contains(html, "
  • Charity Majors") { + t.Errorf("expected plain text reference: %s", html) + } +} + +func TestReferencesBlockEmpty(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "references", + "props": map[string]any{"items": "[]"}, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, `class="bn-references"`) { + t.Errorf("expected bn-references wrapper even when empty: %s", html) + } +} + +func TestReferencesBlockMalformedJSON(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "references", + "props": map[string]any{"items": "not valid json"}, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, `class="bn-references"`) { + t.Errorf("expected graceful fallback: %s", html) + } +} + +func TestEmptyDocument(t *testing.T) { + html := BlockNoteToHTML(context.Background(), map[string]any{}) + if html != "" { + t.Errorf("expected empty output for empty doc: %s", html) + } +} + +func TestUnknownBlockType(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "unknownBlock", + "content": "Some content", + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, "Some content") { + t.Errorf("expected fallback rendering of unknown block: %s", html) + } +}