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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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
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