Compare commits

..

2 Commits
v0.6.0 ... main

Author SHA1 Message Date
Alex Dunmow
7eb3e27053 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 <noreply@anthropic.com>
2026-05-03 10:18:32 +08:00
Alex Dunmow
9c62780246 feat: add context-aware BlockNote SDK bridge 2026-05-03 08:36:08 +08:00
10 changed files with 1053 additions and 25 deletions

50
Makefile Normal file
View File

@ -0,0 +1,50 @@
SDK_MODULE := git.dev.alexdunmow.com/block/core
SDK_VERSION ?= $(shell git describe --tags --abbrev=0)
SDK_DOWNSTREAM_DIRS := \
$(HOME)/src/blockninja/backend \
$(HOME)/src/assumechaos \
$(HOME)/src/bidbuddy \
$(HOME)/src/bidmasters \
$(HOME)/src/coterieos \
$(HOME)/src/messenger \
$(HOME)/src/perthplaygrounds \
$(HOME)/src/symposium
.PHONY: update-sdk
update-sdk:
@set -e; \
for dir in $(SDK_DOWNSTREAM_DIRS); do \
if [ ! -f "$$dir/go.mod" ]; then \
echo "skip $$dir (no go.mod)"; \
continue; \
fi; \
echo "==> $$dir: $(SDK_MODULE)@$(SDK_VERSION)"; \
( \
cd "$$dir"; \
go mod edit -dropreplace=$(SDK_MODULE) 2>/dev/null || true; \
go get $(SDK_MODULE)@$(SDK_VERSION); \
go mod tidy; \
if grep -q '^replace $(SDK_MODULE)' go.mod; then \
echo "replace directive still present in $$dir/go.mod" >&2; \
exit 1; \
fi; \
); \
done
.PHONY: check-sdk-pins
check-sdk-pins:
@set -e; \
for dir in $(SDK_DOWNSTREAM_DIRS); do \
if [ ! -f "$$dir/go.mod" ]; then \
continue; \
fi; \
if grep -q '^replace $(SDK_MODULE)' "$$dir/go.mod"; then \
echo "replace directive found in $$dir/go.mod" >&2; \
exit 1; \
fi; \
if ! grep -Eq '^[[:space:]]*$(SDK_MODULE)[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+' "$$dir/go.mod"; then \
echo "$(SDK_MODULE) is not pinned in $$dir/go.mod" >&2; \
exit 1; \
fi; \
done

View File

@ -6,5 +6,7 @@ import "io/fs"
type BlockRegistry interface { type BlockRegistry interface {
Register(meta BlockMeta, fn BlockFunc) Register(meta BlockMeta, fn BlockFunc)
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc) RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn BlockFunc)
LoadSchemasFromFS(fsys fs.FS) error LoadSchemasFromFS(fsys fs.FS) error
LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error
} }

View File

@ -35,13 +35,13 @@ type PostInfo struct {
} }
// Content provides content access for plugins. // 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 { type Content interface {
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error) GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
GetPage(ctx context.Context, slug string) (*PageInfo, error) GetPage(ctx context.Context, slug string) (*PageInfo, error)
GetPost(ctx context.Context, slug string) (*PostInfo, error) GetPost(ctx context.Context, slug string) (*PostInfo, error)
Slugify(text string) string Slugify(text string) string
BlockNoteToHTML(doc map[string]any) string BlockNoteToHTML(ctx context.Context, doc map[string]any) string
GenerateExcerpt(html string, maxLen int) string GenerateExcerpt(html string, maxLen int) string
StripHTML(s string) string StripHTML(s string) string
} }

View File

@ -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"`
}

View File

@ -2,6 +2,7 @@ package plugin
import ( import (
"io/fs" "io/fs"
"strings"
"git.dev.alexdunmow.com/block/core/blocks" "git.dev.alexdunmow.com/block/core/blocks"
) )
@ -18,15 +19,25 @@ func NewPluginBlockRegistry(inner blocks.BlockRegistry, pluginName string) *Plug
} }
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) { func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
if !strings.Contains(meta.Key, ":") {
meta.Key = r.prefix + ":" + meta.Key meta.Key = r.prefix + ":" + meta.Key
}
meta.Source = r.prefix meta.Source = r.prefix
r.inner.Register(meta, fn) r.inner.Register(meta, fn)
} }
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) { func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
r.inner.RegisterTemplateOverride(templateKey, blockKey, fn) r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, r.prefix, fn)
}
func (r *PluginBlockRegistry) RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn blocks.BlockFunc) {
r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, source, fn)
} }
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error { func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
return r.inner.LoadSchemasFromFS(fsys) return r.inner.LoadSchemasFromFSWithPrefix(fsys, r.prefix)
}
func (r *PluginBlockRegistry) LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error {
return r.inner.LoadSchemasFromFSWithPrefix(fsys, prefix)
} }

View File

@ -0,0 +1,70 @@
package plugin
import (
"io/fs"
"testing"
"git.dev.alexdunmow.com/block/core/blocks"
)
type recordingBlockRegistry struct {
registeredKey string
registeredSource string
overrideSource string
schemaPrefix string
}
func (r *recordingBlockRegistry) Register(meta blocks.BlockMeta, _ blocks.BlockFunc) {
r.registeredKey = meta.Key
r.registeredSource = meta.Source
}
func (r *recordingBlockRegistry) RegisterTemplateOverride(_, _ string, _ blocks.BlockFunc) {}
func (r *recordingBlockRegistry) RegisterTemplateOverrideWithSource(_, _, source string, _ blocks.BlockFunc) {
r.overrideSource = source
}
func (r *recordingBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
return r.LoadSchemasFromFSWithPrefix(fsys, "")
}
func (r *recordingBlockRegistry) LoadSchemasFromFSWithPrefix(_ fs.FS, prefix string) error {
r.schemaPrefix = prefix
return nil
}
func TestPluginBlockRegistryPrefixesOnlyUnqualifiedKeys(t *testing.T) {
inner := &recordingBlockRegistry{}
registry := NewPluginBlockRegistry(inner, "course")
registry.Register(blocks.BlockMeta{Key: "lesson"}, nil)
if inner.registeredKey != "course:lesson" {
t.Fatalf("Register() key = %q, want course:lesson", inner.registeredKey)
}
if inner.registeredSource != "course" {
t.Fatalf("Register() source = %q, want course", inner.registeredSource)
}
registry.Register(blocks.BlockMeta{Key: "course:lesson"}, nil)
if inner.registeredKey != "course:lesson" {
t.Fatalf("Register() double-prefixed qualified key: %q", inner.registeredKey)
}
}
func TestPluginBlockRegistryTracksOverrideAndSchemaSource(t *testing.T) {
inner := &recordingBlockRegistry{}
registry := NewPluginBlockRegistry(inner, "course")
registry.RegisterTemplateOverride("shared-theme", "lesson", nil)
if inner.overrideSource != "course" {
t.Fatalf("override source = %q, want course", inner.overrideSource)
}
if err := registry.LoadSchemasFromFS(nil); err != nil {
t.Fatalf("LoadSchemasFromFS() error = %v", err)
}
if inner.schemaPrefix != "course" {
t.Fatalf("schema prefix = %q, want course", inner.schemaPrefix)
}
}

View File

@ -8,6 +8,7 @@ import (
"git.dev.alexdunmow.com/block/core/auth" "git.dev.alexdunmow.com/block/core/auth"
"git.dev.alexdunmow.com/block/core/content" "git.dev.alexdunmow.com/block/core/content"
"git.dev.alexdunmow.com/block/core/crypto" "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/gating"
"git.dev.alexdunmow.com/block/core/menus" "git.dev.alexdunmow.com/block/core/menus"
"git.dev.alexdunmow.com/block/core/settings" "git.dev.alexdunmow.com/block/core/settings"
@ -15,14 +16,15 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// ServiceDeps provides dependencies that plugins need for RPC service handlers. // CoreServices provides CMS capabilities to plugins.
type ServiceDeps struct { type CoreServices struct {
// Capability interfaces — typed access to CMS functionality // Capability interfaces — typed access to CMS functionality
Content content.Content Content content.Content
Settings settings.Settings Settings settings.Settings
Gating gating.Gating Gating gating.Gating
Crypto crypto.Crypto Crypto crypto.Crypto
Menus menus.Menus Menus menus.Menus
Datasources datasources.Datasources
PublicUsers auth.PublicUsers PublicUsers auth.PublicUsers
Subscriptions subscriptions.Subscriptions Subscriptions subscriptions.Subscriptions

View File

@ -27,19 +27,19 @@ type PluginRegistration struct {
BundledFonts func() []byte BundledFonts func() []byte
MasterPages func() []MasterPageDefinition MasterPages func() []MasterPageDefinition
HTTPHandler func(deps ServiceDeps) http.Handler HTTPHandler func(deps CoreServices) http.Handler
SettingsPanel func() string SettingsPanel func() string
AdminPages func() []AdminPage AdminPages func() []AdminPage
CSSManifest func() *CSSManifest CSSManifest func() *CSSManifest
ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error) ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error)
JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc JobHandlers func(deps CoreServices) map[string]JobHandlerFunc
AIActions func() []AIAction AIActions func() []AIAction
DirectoryExtensions func() *DirectoryExtensions DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider MediaHooks MediaHooksProvider
Load func(deps ServiceDeps) error Load func(deps CoreServices) error
Unload func(ctx context.Context) error Unload func(ctx context.Context) error
Dependencies []Dependency Dependencies []Dependency

View File

@ -1,22 +1,26 @@
package render package render
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"strings" "strings"
"git.dev.alexdunmow.com/block/core/blocks"
"github.com/google/uuid"
) )
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML. // BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
func BlockNoteToHTML(doc map[string]any) string { func BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
blocks := blocksFromRaw(doc["blocks"]) rawBlocks := blocksFromRaw(doc["blocks"])
if len(blocks) == 0 { if len(rawBlocks) == 0 {
return "" return ""
} }
return renderBlocks(blocks) return renderBlocks(ctx, rawBlocks)
} }
func renderBlocks(blocks []map[string]any) string { func renderBlocks(ctx context.Context, blocks []map[string]any) string {
var sb strings.Builder var sb strings.Builder
var currentListType string var currentListType string
var listItems []map[string]any var listItems []map[string]any
@ -34,7 +38,7 @@ func renderBlocks(blocks []map[string]any) string {
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle) fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
for _, item := range listItems { for _, item := range listItems {
content := inlineContentFromRaw(item["content"]) content := inlineContentFromRaw(item["content"])
childrenHTML := renderChildren(item["children"]) childrenHTML := renderChildren(ctx, item["children"])
sb.WriteString("<li>") sb.WriteString("<li>")
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
@ -60,7 +64,7 @@ func renderBlocks(blocks []map[string]any) string {
} }
flushList() flushList()
sb.WriteString(renderBlock(blockMap)) sb.WriteString(renderBlock(ctx, blockMap))
} }
flushList() flushList()
@ -106,17 +110,90 @@ func blocksFromRaw(raw any) []map[string]any {
} }
} }
func renderBlock(block map[string]any) string { 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) blockType, _ := block["type"].(string)
props, _ := block["props"].(map[string]any) props, _ := block["props"].(map[string]any)
content := inlineContentFromRaw(block["content"]) content := inlineContentFromRaw(block["content"])
childrenHTML := renderChildren(block["children"]) childrenHTML := renderChildren(ctx, block["children"])
var sb strings.Builder var sb strings.Builder
switch blockType { switch blockType {
case "paragraph": case "paragraph":
sb.WriteString("<p class=\"my-4\">") classNames := "my-4"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
@ -136,7 +213,11 @@ func renderBlock(block map[string]any) string {
if level > 6 { if level > 6 {
level = 6 level = 6
} }
fmt.Fprintf(&sb, "<h%d class=\"mt-8 mb-4 font-bold\">", level) classNames := "mt-8 mb-4 font-bold"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
@ -144,13 +225,48 @@ func renderBlock(block map[string]any) string {
fmt.Fprintf(&sb, "</h%d>\n", level) fmt.Fprintf(&sb, "</h%d>\n", level)
case "quote": case "quote":
sb.WriteString("<blockquote class=\"my-4 border-l-4 border-border pl-4 text-muted-foreground\">") classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
} }
sb.WriteString("</blockquote>\n") sb.WriteString("</blockquote>\n")
case "checkListItem":
checked := false
if c, ok := props["checked"].(bool); ok {
checked = c
}
checkedAttr := ""
if checked {
checkedAttr = " checked"
}
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</span>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
}
sb.WriteString("</div>\n")
case "toggleListItem":
openAttr := ""
if open, ok := props["open"].(bool); ok && open {
openAttr = " open"
}
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</summary>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
}
sb.WriteString("</details>\n")
case "codeBlock": case "codeBlock":
lang := "" lang := ""
if l, ok := props["language"].(string); ok { if l, ok := props["language"].(string); ok {
@ -183,6 +299,67 @@ func renderBlock(block map[string]any) string {
} }
sb.WriteString("</figure>\n") sb.WriteString("</figure>\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(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\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(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\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(`<div class="my-4 rounded border border-border p-4">`)
if url != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>")
} else {
sb.WriteString(html.EscapeString(name))
}
if caption != "" {
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
}
sb.WriteString("</div>\n")
case "table": case "table":
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`) sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
if tableContent, ok := block["content"].(map[string]any); ok { if tableContent, ok := block["content"].(map[string]any); ok {
@ -220,6 +397,45 @@ func renderBlock(block map[string]any) string {
} }
sb.WriteString("</table></div>\n") sb.WriteString("</table></div>\n")
case "embed":
if resolver := blocks.GetEmbedResolver(ctx); resolver != nil {
blockID := ""
if v, ok := props["blockId"].(string); ok {
blockID = v
}
dataSource := ""
if v, ok := props["dataSource"].(string); ok {
dataSource = v
}
layout := "full"
if v, ok := props["layout"].(string); ok && v != "" {
layout = v
}
if blockID != "" {
if parsedID, err := uuid.Parse(blockID); err == nil {
sb.WriteString(resolver.RenderEmbed(ctx, parsedID, dataSource, layout))
}
}
}
case "statement":
sb.WriteString("<div class=\"bn-statement\">\n")
if len(content) > 0 {
sb.WriteString("<p>")
sb.WriteString(renderInlineContent(content))
sb.WriteString("</p>\n")
}
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</div>\n")
case "humanProof":
if hp := blocks.GetHumanProofBanner(ctx); hp != nil {
sb.WriteString(blocks.RenderHumanProofBanner(hp))
sb.WriteByte('\n')
}
case "references": case "references":
sb.WriteString("<div class=\"bn-references\">\n") sb.WriteString("<div class=\"bn-references\">\n")
sb.WriteString("<div class=\"bn-references-label\">References</div>\n") sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
@ -257,12 +473,12 @@ func renderBlock(block map[string]any) string {
return sb.String() return sb.String()
} }
func renderChildren(children any) string { func renderChildren(ctx context.Context, children any) string {
blocks := blocksFromRaw(children) blocks := blocksFromRaw(children)
if len(blocks) == 0 { if len(blocks) == 0 {
return "" return ""
} }
return renderBlocks(blocks) return renderBlocks(ctx, blocks)
} }
func renderInlineContent(content []map[string]any) string { func renderInlineContent(content []map[string]any) string {
@ -294,6 +510,22 @@ func renderInlineContent(content []map[string]any) string {
if code, ok := styles["code"].(bool); ok && code { if code, ok := styles["code"].(bool); ok && code {
rendered = "<code>" + rendered + "</code>" rendered = "<code>" + rendered + "</code>"
} }
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(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
}
} }
sb.WriteString(rendered) sb.WriteString(rendered)

640
render/blocknote_test.go Normal file
View File

@ -0,0 +1,640 @@
package render
import (
"context"
"strings"
"testing"
"git.dev.alexdunmow.com/block/core/blocks"
"github.com/google/uuid"
)
type testEmbedResolver struct{}
func (testEmbedResolver) RenderEmbed(_ context.Context, blockID uuid.UUID, dataSource, layout string) string {
return "embed:" + blockID.String() + ":" + dataSource + ":" + layout
}
func TestBlockNoteToHTMLUsesSDKContextResolvers(t *testing.T) {
blockID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
ctx := blocks.WithEmbedResolver(context.Background(), testEmbedResolver{})
ctx = blocks.WithHumanProofBanner(ctx, &blocks.HumanProofBannerData{
ActiveTimeMinutes: 12,
KeystrokeCount: 3456,
SessionCount: 2,
PostSlug: "proof-post",
})
html := BlockNoteToHTML(ctx, map[string]any{
"blocks": []any{
map[string]any{
"type": "embed",
"props": map[string]any{
"blockId": blockID.String(),
"dataSource": "row:22222222-2222-2222-2222-222222222222",
"layout": "card",
},
},
map[string]any{"type": "humanProof"},
},
})
if !strings.Contains(html, "embed:"+blockID.String()+":row:22222222-2222-2222-2222-222222222222:card") {
t.Fatalf("BlockNoteToHTML() did not render embed from SDK context: %s", html)
}
if !strings.Contains(html, `data-human-proof-banner`) || !strings.Contains(html, `proof-post`) {
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, "<details") {
t.Errorf("expected details element: %s", html)
}
if !strings.Contains(html, "<summary") {
t.Errorf("expected summary element: %s", html)
}
if !strings.Contains(html, "Nested content") {
t.Errorf("expected nested content: %s", html)
}
}
func TestToggleListItemOpen(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "toggleListItem",
"props": map[string]any{"open": true},
"content": "Open toggle",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, ` open`) {
t.Errorf("expected open attribute: %s", html)
}
}
func TestVideoBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "video",
"props": map[string]any{
"url": "https://example.com/video.mp4",
"caption": "My video",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `<video`) {
t.Errorf("expected video element: %s", html)
}
if !strings.Contains(html, `controls`) {
t.Errorf("expected controls attribute: %s", html)
}
if !strings.Contains(html, "My video") {
t.Errorf("expected caption: %s", html)
}
}
func TestAudioBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "audio",
"props": map[string]any{
"url": "https://example.com/audio.mp3",
"caption": "Podcast episode",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `<audio`) {
t.Errorf("expected audio element: %s", html)
}
if !strings.Contains(html, `controls`) {
t.Errorf("expected controls attribute: %s", html)
}
if !strings.Contains(html, "Podcast episode") {
t.Errorf("expected caption: %s", html)
}
}
func TestFileBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "https://example.com/doc.pdf",
"name": "Document.pdf",
"caption": "Download here",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `href="https://example.com/doc.pdf"`) {
t.Errorf("expected file link: %s", html)
}
if !strings.Contains(html, "Document.pdf") {
t.Errorf("expected file name: %s", html)
}
if !strings.Contains(html, "Download here") {
t.Errorf("expected caption: %s", html)
}
}
func TestFileBlockNoName(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "https://example.com/doc.pdf",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "https://example.com/doc.pdf") {
t.Errorf("expected URL as fallback name: %s", html)
}
}
func TestStatementBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "statement",
"content": []any{
map[string]any{
"type": "text",
"text": "This is a key statement.",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `class="bn-statement"`) {
t.Errorf("expected bn-statement class: %s", html)
}
if !strings.Contains(html, "This is a key statement.") {
t.Errorf("expected statement text: %s", html)
}
}
func TestStatementBlockWithFormatting(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "statement",
"content": []any{
map[string]any{
"type": "text",
"text": "Bold text",
"styles": map[string]any{"bold": true},
},
map[string]any{
"type": "text",
"text": " and normal.",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<strong>Bold text</strong>") {
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, "<strong>") {
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, "<p") {
t.Errorf("expected paragraph tag: %s", html)
}
if !strings.Contains(html, `text-[#ff0000]`) {
t.Errorf("expected text color class: %s", html)
}
}
func TestBulletListStringContent(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "bulletListItem",
"content": "Test bullet content",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Test bullet content") {
t.Errorf("expected bullet content: %s", html)
}
}
func TestTableSingleRowDoesNotEmitTbody(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{
[]any{map[string]any{"type": "text", "text": "Header"}},
},
},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<thead>") {
t.Errorf("expected thead: %s", html)
}
if strings.Contains(html, "<tbody>") {
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, "<tbody>") {
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, "<ul") < 2 {
t.Errorf("expected nested ul elements: %s", html)
}
}
func TestHardBreakInlineContent(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{"type": "text", "text": "Line1"},
map[string]any{"type": "hardBreak"},
map[string]any{"type": "text", "text": "Line2"},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Line1") || !strings.Contains(html, "Line2") || !strings.Contains(html, "<br />") {
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, `<a href="https://example.com/blank">Steve Blank`) {
t.Errorf("expected linked reference: %s", html)
}
if !strings.Contains(html, "<li>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)
}
}