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 {
Register(meta BlockMeta, fn BlockFunc)
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn BlockFunc)
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)
GetPost(ctx context.Context, slug string) (*PostInfo, error)
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
StripHTML(s string) string
}

View File

@ -2,6 +2,7 @@ package plugin
import (
"io/fs"
"strings"
"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) {
if !strings.Contains(meta.Key, ":") {
meta.Key = r.prefix + ":" + meta.Key
}
meta.Source = r.prefix
r.inner.Register(meta, fn)
}
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 {
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
import (
"context"
"encoding/json"
"fmt"
"html"
"strings"
"git.dev.alexdunmow.com/block/core/blocks"
"github.com/google/uuid"
)
// 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"])
if len(blocks) == 0 {
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 currentListType string
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)
for _, item := range listItems {
content := inlineContentFromRaw(item["content"])
childrenHTML := renderChildren(item["children"])
childrenHTML := renderChildren(ctx, item["children"])
sb.WriteString("<li>")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
@ -60,7 +64,7 @@ func renderBlocks(blocks []map[string]any) string {
}
flushList()
sb.WriteString(renderBlock(blockMap))
sb.WriteString(renderBlock(ctx, blockMap))
}
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)
props, _ := block["props"].(map[string]any)
content := inlineContentFromRaw(block["content"])
childrenHTML := renderChildren(block["children"])
childrenHTML := renderChildren(ctx, block["children"])
var sb strings.Builder
@ -220,6 +224,33 @@ func renderBlock(block map[string]any) string {
}
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":
sb.WriteString("<div class=\"bn-references\">\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()
}
func renderChildren(children any) string {
func renderChildren(ctx context.Context, children any) string {
blocks := blocksFromRaw(children)
if len(blocks) == 0 {
return ""
}
return renderBlocks(blocks)
return renderBlocks(ctx, blocks)
}
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)
}
}