feat: add context-aware BlockNote SDK bridge

This commit is contained in:
Alex Dunmow 2026-05-03 08:36:08 +08:00
parent b2c968af41
commit 9c62780246
7 changed files with 225 additions and 13 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

@ -41,7 +41,7 @@ type Content interface {
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

@ -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) {
meta.Key = r.prefix + ":" + meta.Key if !strings.Contains(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

@ -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"]) blocks := blocksFromRaw(doc["blocks"])
if len(blocks) == 0 { if len(blocks) == 0 {
return "" return ""
} }
return renderBlocks(blocks) return renderBlocks(ctx, blocks)
} }
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,11 +110,11 @@ func blocksFromRaw(raw any) []map[string]any {
} }
} }
func renderBlock(block map[string]any) string { 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
@ -220,6 +224,33 @@ 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 "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 +288,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 {

48
render/blocknote_test.go Normal file
View File

@ -0,0 +1,48 @@
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)
}
}