feat: add context-aware BlockNote SDK bridge
This commit is contained in:
parent
b2c968af41
commit
9c62780246
50
Makefile
Normal file
50
Makefile
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
70
plugin/block_registry_test.go
Normal file
70
plugin/block_registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
48
render/blocknote_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user