From 9c62780246a70b8536bb460fa3932618ec3cd8af Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sun, 3 May 2026 08:36:08 +0800 Subject: [PATCH] feat: add context-aware BlockNote SDK bridge --- Makefile | 50 +++++++++++++++++++++++++ blocks/registry.go | 2 + content/content.go | 2 +- plugin/block_registry.go | 17 +++++++-- plugin/block_registry_test.go | 70 +++++++++++++++++++++++++++++++++++ render/blocknote.go | 49 +++++++++++++++++++----- render/blocknote_test.go | 48 ++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 Makefile create mode 100644 plugin/block_registry_test.go create mode 100644 render/blocknote_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60d3900 --- /dev/null +++ b/Makefile @@ -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 diff --git a/blocks/registry.go b/blocks/registry.go index c70e1b1..cd1e3ef 100644 --- a/blocks/registry.go +++ b/blocks/registry.go @@ -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 } diff --git a/content/content.go b/content/content.go index b67ae95..5c6b472 100644 --- a/content/content.go +++ b/content/content.go @@ -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 } diff --git a/plugin/block_registry.go b/plugin/block_registry.go index 5ee5d07..b461861 100644 --- a/plugin/block_registry.go +++ b/plugin/block_registry.go @@ -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) { - meta.Key = r.prefix + ":" + meta.Key + 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) } diff --git a/plugin/block_registry_test.go b/plugin/block_registry_test.go new file mode 100644 index 0000000..e004673 --- /dev/null +++ b/plugin/block_registry_test.go @@ -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) + } +} diff --git a/render/blocknote.go b/render/blocknote.go index 5e6df56..1828da3 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -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("
  • ") 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("\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("
    \n") sb.WriteString("
    References
    \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 { diff --git a/render/blocknote_test.go b/render/blocknote_test.go new file mode 100644 index 0000000..5ffdf29 --- /dev/null +++ b/render/blocknote_test.go @@ -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) + } +}