Compare commits

..

10 Commits
v0.3.0 ... main

Author SHA1 Message Date
Alex Dunmow
7eb3e27053 feat: converge BlockNote renderer, add datasources bridge, rename ServiceDeps to CoreServices
SDK renderer now has full feature parity with the host: text alignment,
checkListItem, toggleListItem, video, audio, file, statement blocks,
and text/background color inline styles. New datasources.Datasources
interface lets plugins resolve buckets directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 10:18:32 +08:00
Alex Dunmow
9c62780246 feat: add context-aware BlockNote SDK bridge 2026-05-03 08:36:08 +08:00
Alex Dunmow
b2c968af41 feat: add SymposiumSeeder and MessengerSeeder bridge interfaces
Defines cross-plugin seeder interfaces in the SDK so template plugins
can seed Symposium/Messenger content via PluginBridge without importing
their database packages directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 00:16:46 +08:00
Alex Dunmow
601718a309 feat: add Load/Unload lifecycle hooks to PluginRegistration
Enables runtime plugin disable/enable without CMS restart.
Load is called after registration succeeds; Unload on disable
with a 30-second context deadline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 22:28:49 +08:00
Alex Dunmow
334d79b4bf fix: EnsureSetting accepts any value type for JSON-serializable settings
Plugins store arrays and scalars as settings, not just maps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 12:13:39 +08:00
Alex Dunmow
a2a56f642c fix: UpdateDataTableRowField accepts any value type, not just maps
The provisioner just marshals the value to JSON regardless of type.
Restricting to map[string]any prevented plugins from setting scalar
field values (strings, numbers, booleans).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 12:10:36 +08:00
Alex Dunmow
62c25a7b3a feat: add settings.Updater interface to ServiceDeps 2026-05-02 11:03:40 +08:00
Alex Dunmow
2c89ce4d42 feat: add BadgeRefresher interface and badge types 2026-05-02 11:03:09 +08:00
Alex Dunmow
1ede7d50be feat: add ReviewSubmitter interface to ServiceDeps 2026-05-02 11:02:28 +08:00
Alex Dunmow
a174eb943d feat: add CoreServiceBindings interface to ServiceDeps 2026-05-02 11:02:08 +08:00
16 changed files with 1259 additions and 26 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

42
badges/badges.go Normal file
View File

@ -0,0 +1,42 @@
package badges
import "encoding/json"
// BadgeRule defines a condition for automatic badge assignment.
type BadgeRule struct {
Field string `json:"field"`
Operator string `json:"operator"` // eq, neq, gt, gte, lt, lte, contains, exists, true, false
Value any `json:"value"`
}
// BadgeDefinition defines a badge with auto-computation rules.
type BadgeDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
Icon string `json:"icon"`
Rules []BadgeRule `json:"rules"`
RulesMode string `json:"rules_mode"` // "and" or "or"
ManualOnly bool `json:"manual_only"`
}
// ParseDefinitions extracts badge definitions from a data table schema JSON.
func ParseDefinitions(schemaJSON []byte) []BadgeDefinition {
var schema map[string]any
if err := json.Unmarshal(schemaJSON, &schema); err != nil {
return nil
}
badgesRaw, ok := schema["badges"]
if !ok {
return nil
}
raw, err := json.Marshal(badgesRaw)
if err != nil {
return nil
}
var definitions []BadgeDefinition
if err := json.Unmarshal(raw, &definitions); err != nil {
return nil
}
return definitions
}

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

@ -35,13 +35,13 @@ type PostInfo struct {
} }
// Content provides content access for plugins. // Content provides content access for plugins.
// The CMS implements this interface and wires it into ServiceDeps. // The CMS implements this interface and wires it into CoreServices.
type Content interface { type Content interface {
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error) GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
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

@ -0,0 +1,21 @@
package datasources
import (
"context"
"github.com/google/uuid"
)
// Datasources provides bucket/datasource access for plugins.
// The CMS implements this interface and wires it into CoreServices.
type Datasources interface {
ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*Result, error)
ResolveBucketByKey(ctx context.Context, bucketKey string) (*Result, error)
}
// Result is the output from resolving a bucket.
type Result struct {
Items []any `json:"items"`
Total int `json:"total"`
Meta map[string]any `json:"meta,omitempty"`
}

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

13
plugin/community.go Normal file
View File

@ -0,0 +1,13 @@
package plugin
import (
"git.dev.alexdunmow.com/block/core/rbac"
)
// CoreServiceBindings provides pre-built CMS service bindings that plugins
// can include in their ServiceRegistration with custom RBAC role mappings.
// The CMS constructs the actual service handlers — plugins just specify which
// services to mount and what roles apply.
type CoreServiceBindings interface {
Bind(serviceName string, methodRoles map[string]rbac.Role) (ConnectServiceBinding, error)
}

View File

@ -8,6 +8,7 @@ import (
"git.dev.alexdunmow.com/block/core/auth" "git.dev.alexdunmow.com/block/core/auth"
"git.dev.alexdunmow.com/block/core/content" "git.dev.alexdunmow.com/block/core/content"
"git.dev.alexdunmow.com/block/core/crypto" "git.dev.alexdunmow.com/block/core/crypto"
"git.dev.alexdunmow.com/block/core/datasources"
"git.dev.alexdunmow.com/block/core/gating" "git.dev.alexdunmow.com/block/core/gating"
"git.dev.alexdunmow.com/block/core/menus" "git.dev.alexdunmow.com/block/core/menus"
"git.dev.alexdunmow.com/block/core/settings" "git.dev.alexdunmow.com/block/core/settings"
@ -15,14 +16,15 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// ServiceDeps provides dependencies that plugins need for RPC service handlers. // CoreServices provides CMS capabilities to plugins.
type ServiceDeps struct { type CoreServices struct {
// Capability interfaces — typed access to CMS functionality // Capability interfaces — typed access to CMS functionality
Content content.Content Content content.Content
Settings settings.Settings Settings settings.Settings
Gating gating.Gating Gating gating.Gating
Crypto crypto.Crypto Crypto crypto.Crypto
Menus menus.Menus Menus menus.Menus
Datasources datasources.Datasources
PublicUsers auth.PublicUsers PublicUsers auth.PublicUsers
Subscriptions subscriptions.Subscriptions Subscriptions subscriptions.Subscriptions
@ -46,6 +48,12 @@ type ServiceDeps struct {
// Plugin interop // Plugin interop
Bridge PluginBridge Bridge PluginBridge
// Core RPC services — pre-built bindings for CMS-provided services
CoreServiceBindings CoreServiceBindings
ReviewSubmitter ReviewSubmitter
BadgeRefresher BadgeRefresher
SettingsUpdater settings.Updater
// Extension points — typed as narrow interfaces where possible // Extension points — typed as narrow interfaces where possible
JobRunner JobRunner JobRunner JobRunner
EmbeddingService EmbeddingService EmbeddingService EmbeddingService
@ -81,3 +89,10 @@ type RAGResult struct {
Score float64 Score float64
Metadata map[string]string Metadata map[string]string
} }
// BadgeRefresher recomputes badges for a data table row.
// The CMS handles loading the table schema, aggregating ratings,
// evaluating badge rules, and persisting the updated badge list.
type BadgeRefresher interface {
RefreshBadges(ctx context.Context, tableID, rowID uuid.UUID) error
}

View File

@ -12,14 +12,14 @@ import (
type Provisioner interface { type Provisioner interface {
EnsureDataTable(config DataTableConfig) error EnsureDataTable(config DataTableConfig) error
MergeSiteSettings(defaults map[string]any) error MergeSiteSettings(defaults map[string]any) error
EnsureSetting(key string, defaultValue map[string]any) error EnsureSetting(key string, defaultValue any) error
EnsurePage(config PageConfig) error EnsurePage(config PageConfig) error
OverrideSiteSettings(overrides map[string]any) error OverrideSiteSettings(overrides map[string]any) error
EnsureMenuItem(menuName string, config MenuItemConfig) error EnsureMenuItem(menuName string, config MenuItemConfig) error
RegisterEmbeddingConfig(config EmbeddingConfigDef) error RegisterEmbeddingConfig(config EmbeddingConfigDef) error
EnsureEmbed(config EmbedConfig) error EnsureEmbed(config EmbedConfig) error
EnsureJobSchedule(config JobScheduleConfig) error EnsureJobSchedule(config JobScheduleConfig) error
UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value map[string]any) error UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value any) error
DisableOrphanedJobSchedules(registeredTypes []string) error DisableOrphanedJobSchedules(registeredTypes []string) error
EnsurePlugin(name string) error EnsurePlugin(name string) error
EnsureCustomColor(config CustomColorConfig) error EnsureCustomColor(config CustomColorConfig) error

View File

@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"context"
"io/fs" "io/fs"
"net/http" "net/http"
@ -26,18 +27,21 @@ type PluginRegistration struct {
BundledFonts func() []byte BundledFonts func() []byte
MasterPages func() []MasterPageDefinition MasterPages func() []MasterPageDefinition
HTTPHandler func(deps ServiceDeps) http.Handler HTTPHandler func(deps CoreServices) http.Handler
SettingsPanel func() string SettingsPanel func() string
AdminPages func() []AdminPage AdminPages func() []AdminPage
CSSManifest func() *CSSManifest CSSManifest func() *CSSManifest
ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error) ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error)
JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc JobHandlers func(deps CoreServices) map[string]JobHandlerFunc
AIActions func() []AIAction AIActions func() []AIAction
DirectoryExtensions func() *DirectoryExtensions DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider MediaHooks MediaHooksProvider
Load func(deps CoreServices) error
Unload func(ctx context.Context) error
Dependencies []Dependency Dependencies []Dependency
Migrations func() fs.FS Migrations func() fs.FS
Version string Version string

23
plugin/reviews.go Normal file
View File

@ -0,0 +1,23 @@
package plugin
import (
"context"
"github.com/google/uuid"
)
// ReviewSubmitter allows plugins to submit community reviews programmatically
// without importing CMS proto types.
type ReviewSubmitter interface {
SubmitReview(ctx context.Context, params SubmitReviewParams) (reviewID string, err error)
}
// SubmitReviewParams contains the fields needed to submit a community review.
type SubmitReviewParams struct {
TableID uuid.UUID
RowID uuid.UUID
OverallRating int32
ReviewText string
Ratings map[string]any
Photos []string
}

105
plugin/seeder.go Normal file
View File

@ -0,0 +1,105 @@
package plugin
import (
"context"
"github.com/jackc/pgx/v5"
)
// SymposiumSeeder provides Symposium data-seeding and lookup operations for
// cross-plugin use via PluginBridge. Template plugins retrieve this interface
// instead of importing the Symposium database package directly.
//
// All Seed* methods are idempotent: existing rows are returned, not duplicated.
type SymposiumSeeder interface {
// Wiki
SeedWikiCategory(ctx context.Context, tx pgx.Tx, name, slug, description string, sortOrder int32) (string, error)
SeedWikiArticle(ctx context.Context, tx pgx.Tx, title, slug, categoryID, excerpt string, contentJSON []byte) error
// Courses
SeedCourse(ctx context.Context, tx pgx.Tx, p SeedCourseParams) (string, error)
SeedCourseSection(ctx context.Context, tx pgx.Tx, courseID, title string, position int32) (string, error)
SeedLesson(ctx context.Context, tx pgx.Tx, courseID, sectionID string, p SeedLessonParams) (string, error)
// Quizzes
SeedQuiz(ctx context.Context, tx pgx.Tx, title string, passThreshold int32) (string, error)
SeedQuizQuestion(ctx context.Context, tx pgx.Tx, quizID string, p SeedQuizQuestionParams) (string, error)
// Community
SeedCommunityCategory(ctx context.Context, tx pgx.Tx, name, slug, description, icon string, position, minTierLevel int32) (string, error)
// Course categories & tags
SeedCourseCategory(ctx context.Context, tx pgx.Tx, name, slug string, description *string, position int32) error
SeedCourseTag(ctx context.Context, tx pgx.Tx, name, slug string, position *int32) error
// Lookups — return the entity ID or an error if not found.
GetCourseBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error)
GetCommunityCategoryBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error)
// Runtime queries
GetUserCourseProgress(ctx context.Context, pool Pool, userID string) ([]CourseProgressItem, error)
}
// SeedCourseParams holds the parameters for seeding a course.
type SeedCourseParams struct {
Title string
Slug string
Description string
CertificateEnabled bool
MinTierLevel int32
DifficultyLevel string
EstimatedDurationMinutes int32
}
// SeedLessonParams holds the parameters for seeding a lesson.
type SeedLessonParams struct {
Title string
Slug string
LessonType string
ContentJSON []byte
QuizID string
Position int32
IsFreePreview bool
CompletionMode string
}
// SeedQuizQuestionParams holds the parameters for seeding a quiz question.
type SeedQuizQuestionParams struct {
QuestionType string
QuestionText string
Options []map[string]any
CorrectAnswer string
Explanation string
Position int32
Rubric string
ScenarioText string
}
// CourseProgressItem is a lightweight view of a user's progress in one course.
type CourseProgressItem struct {
CourseTitle string
CourseSlug string
NextLesson string
DoneCount int
TotalLessons int
}
// MessengerSeeder provides Messenger data-seeding operations for cross-plugin
// use via PluginBridge. Template plugins retrieve this interface instead of
// importing the Messenger database package directly.
type MessengerSeeder interface {
CountMessagesForPair(ctx context.Context, tx pgx.Tx, participantPairID string) (int64, error)
InsertMessage(ctx context.Context, tx pgx.Tx, p InsertMessageParams) error
}
// InsertMessageParams holds the parameters for inserting a messenger message.
type InsertMessageParams struct {
SenderType string
SenderID string
SenderDisplayName string
RecipientType string
RecipientID string
RecipientDisplayName string
BodyMarkdown string
ParticipantPairID string
}

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"]) rawBlocks := blocksFromRaw(doc["blocks"])
if len(blocks) == 0 { if len(rawBlocks) == 0 {
return "" return ""
} }
return renderBlocks(blocks) return renderBlocks(ctx, rawBlocks)
} }
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,17 +110,90 @@ func blocksFromRaw(raw any) []map[string]any {
} }
} }
func renderBlock(block map[string]any) string { func textAlignClass(props map[string]any) string {
if props == nil {
return ""
}
align, _ := props["textAlignment"].(string)
switch align {
case "left":
return "text-left"
case "center":
return "text-center"
case "right":
return "text-right"
case "justify":
return "text-justify"
default:
return ""
}
}
func safeClassToken(value string) string {
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return ""
}
return value
}
func isHexDigit(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func colorValueToClass(value string, prefix string) string {
if value == "" || value == "default" {
return ""
}
if after, ok := strings.CutPrefix(value, "hex:"); ok {
hex := after
if len(hex) != 7 && len(hex) != 4 {
return ""
}
for i, r := range hex {
if i == 0 && r == '#' {
continue
}
if !isHexDigit(r) {
return ""
}
}
return fmt.Sprintf("%s-[%s]", prefix, hex)
}
if after, ok := strings.CutPrefix(value, "custom:"); ok {
name := safeClassToken(after)
if name == "" {
return ""
}
return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name)
}
value = safeClassToken(value)
if value == "" {
return ""
}
return fmt.Sprintf("%s-%s", prefix, value)
}
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
switch blockType { switch blockType {
case "paragraph": case "paragraph":
sb.WriteString("<p class=\"my-4\">") classNames := "my-4"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
@ -136,7 +213,11 @@ func renderBlock(block map[string]any) string {
if level > 6 { if level > 6 {
level = 6 level = 6
} }
fmt.Fprintf(&sb, "<h%d class=\"mt-8 mb-4 font-bold\">", level) classNames := "mt-8 mb-4 font-bold"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
@ -144,13 +225,48 @@ func renderBlock(block map[string]any) string {
fmt.Fprintf(&sb, "</h%d>\n", level) fmt.Fprintf(&sb, "</h%d>\n", level)
case "quote": case "quote":
sb.WriteString("<blockquote class=\"my-4 border-l-4 border-border pl-4 text-muted-foreground\">") classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
if alignClass := textAlignClass(props); alignClass != "" {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
sb.WriteString(childrenHTML) sb.WriteString(childrenHTML)
} }
sb.WriteString("</blockquote>\n") sb.WriteString("</blockquote>\n")
case "checkListItem":
checked := false
if c, ok := props["checked"].(bool); ok {
checked = c
}
checkedAttr := ""
if checked {
checkedAttr = " checked"
}
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</span>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
}
sb.WriteString("</div>\n")
case "toggleListItem":
openAttr := ""
if open, ok := props["open"].(bool); ok && open {
openAttr = " open"
}
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</summary>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
}
sb.WriteString("</details>\n")
case "codeBlock": case "codeBlock":
lang := "" lang := ""
if l, ok := props["language"].(string); ok { if l, ok := props["language"].(string); ok {
@ -183,6 +299,67 @@ func renderBlock(block map[string]any) string {
} }
sb.WriteString("</figure>\n") sb.WriteString("</figure>\n")
case "video":
url := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if c, ok := props["caption"].(string); ok {
caption = c
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "audio":
url := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if c, ok := props["caption"].(string); ok {
caption = c
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "file":
url := ""
name := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if n, ok := props["name"].(string); ok {
name = n
}
if c, ok := props["caption"].(string); ok {
caption = c
}
if name == "" {
name = url
}
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
if url != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>")
} else {
sb.WriteString(html.EscapeString(name))
}
if caption != "" {
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
}
sb.WriteString("</div>\n")
case "table": case "table":
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`) sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
if tableContent, ok := block["content"].(map[string]any); ok { if tableContent, ok := block["content"].(map[string]any); ok {
@ -220,6 +397,45 @@ 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 "statement":
sb.WriteString("<div class=\"bn-statement\">\n")
if len(content) > 0 {
sb.WriteString("<p>")
sb.WriteString(renderInlineContent(content))
sb.WriteString("</p>\n")
}
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</div>\n")
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 +473,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 {
@ -294,6 +510,22 @@ func renderInlineContent(content []map[string]any) string {
if code, ok := styles["code"].(bool); ok && code { if code, ok := styles["code"].(bool); ok && code {
rendered = "<code>" + rendered + "</code>" rendered = "<code>" + rendered + "</code>"
} }
var colorClasses []string
if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" {
if class := colorValueToClass(textColor, "text"); class != "" {
colorClasses = append(colorClasses, class)
}
}
if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" {
if class := colorValueToClass(bgColor, "bg"); class != "" {
colorClasses = append(colorClasses, class)
}
}
if len(colorClasses) > 0 {
rendered = fmt.Sprintf(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
}
} }
sb.WriteString(rendered) sb.WriteString(rendered)

640
render/blocknote_test.go Normal file
View File

@ -0,0 +1,640 @@
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)
}
}
func TestTextAlignment(t *testing.T) {
tests := []struct {
name string
blockType string
alignment string
wantClass string
}{
{"paragraph center", "paragraph", "center", "text-center"},
{"paragraph right", "paragraph", "right", "text-right"},
{"heading center", "heading", "center", "text-center"},
{"quote justify", "quote", "justify", "text-justify"},
{"paragraph left", "paragraph", "left", "text-left"},
{"paragraph default", "paragraph", "", "my-4"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
props := map[string]any{}
if tt.alignment != "" {
props["textAlignment"] = tt.alignment
}
if tt.blockType == "heading" {
props["level"] = float64(2)
}
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": tt.blockType,
"props": props,
"content": "Test",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, tt.wantClass) {
t.Errorf("expected class %q in output: %s", tt.wantClass, html)
}
})
}
}
func TestCheckListItem(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "checkListItem",
"props": map[string]any{"checked": true},
"content": "Done task",
},
map[string]any{
"type": "checkListItem",
"props": map[string]any{"checked": false},
"content": "Todo task",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `checked`) {
t.Errorf("expected checked attribute: %s", html)
}
if !strings.Contains(html, "Done task") || !strings.Contains(html, "Todo task") {
t.Errorf("expected checklist content: %s", html)
}
if !strings.Contains(html, `type="checkbox"`) {
t.Errorf("expected checkbox input: %s", html)
}
}
func TestToggleListItem(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "toggleListItem",
"content": "Details",
"children": []any{
map[string]any{
"type": "paragraph",
"content": "Nested content",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<details") {
t.Errorf("expected details element: %s", html)
}
if !strings.Contains(html, "<summary") {
t.Errorf("expected summary element: %s", html)
}
if !strings.Contains(html, "Nested content") {
t.Errorf("expected nested content: %s", html)
}
}
func TestToggleListItemOpen(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "toggleListItem",
"props": map[string]any{"open": true},
"content": "Open toggle",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, ` open`) {
t.Errorf("expected open attribute: %s", html)
}
}
func TestVideoBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "video",
"props": map[string]any{
"url": "https://example.com/video.mp4",
"caption": "My video",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `<video`) {
t.Errorf("expected video element: %s", html)
}
if !strings.Contains(html, `controls`) {
t.Errorf("expected controls attribute: %s", html)
}
if !strings.Contains(html, "My video") {
t.Errorf("expected caption: %s", html)
}
}
func TestAudioBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "audio",
"props": map[string]any{
"url": "https://example.com/audio.mp3",
"caption": "Podcast episode",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `<audio`) {
t.Errorf("expected audio element: %s", html)
}
if !strings.Contains(html, `controls`) {
t.Errorf("expected controls attribute: %s", html)
}
if !strings.Contains(html, "Podcast episode") {
t.Errorf("expected caption: %s", html)
}
}
func TestFileBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "https://example.com/doc.pdf",
"name": "Document.pdf",
"caption": "Download here",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `href="https://example.com/doc.pdf"`) {
t.Errorf("expected file link: %s", html)
}
if !strings.Contains(html, "Document.pdf") {
t.Errorf("expected file name: %s", html)
}
if !strings.Contains(html, "Download here") {
t.Errorf("expected caption: %s", html)
}
}
func TestFileBlockNoName(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "https://example.com/doc.pdf",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "https://example.com/doc.pdf") {
t.Errorf("expected URL as fallback name: %s", html)
}
}
func TestStatementBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "statement",
"content": []any{
map[string]any{
"type": "text",
"text": "This is a key statement.",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `class="bn-statement"`) {
t.Errorf("expected bn-statement class: %s", html)
}
if !strings.Contains(html, "This is a key statement.") {
t.Errorf("expected statement text: %s", html)
}
}
func TestStatementBlockWithFormatting(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "statement",
"content": []any{
map[string]any{
"type": "text",
"text": "Bold text",
"styles": map[string]any{"bold": true},
},
map[string]any{
"type": "text",
"text": " and normal.",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<strong>Bold text</strong>") {
t.Errorf("expected bold formatting: %s", html)
}
}
func TestColorValueToClass(t *testing.T) {
tests := []struct {
name string
input string
prefix string
expected string
}{
{"empty", "", "text", ""},
{"default", "default", "text", ""},
{"theme primary text", "primary", "text", "text-primary"},
{"theme primary bg", "primary", "bg", "bg-primary"},
{"theme foreground", "foreground", "text", "text-foreground"},
{"theme muted-foreground", "muted-foreground", "text", "text-muted-foreground"},
{"custom color text", "custom:brand", "text", "text-[hsl(var(--color-brand))]"},
{"custom color bg", "custom:brand", "bg", "bg-[hsl(var(--color-brand))]"},
{"hex color text", "hex:#ff5500", "text", "text-[#ff5500]"},
{"hex color bg", "hex:#ff5500", "bg", "bg-[#ff5500]"},
{"short hex", "hex:#f00", "text", "text-[#f00]"},
{"invalid hex length", "hex:#ff", "text", ""},
{"invalid hex char", "hex:#gggggg", "text", ""},
{"custom with invalid chars", "custom:bad name", "text", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := colorValueToClass(tt.input, tt.prefix)
if got != tt.expected {
t.Errorf("colorValueToClass(%q, %q) = %q, want %q", tt.input, tt.prefix, got, tt.expected)
}
})
}
}
func TestRenderInlineContentWithTextColor(t *testing.T) {
content := []map[string]any{
{
"type": "text",
"text": "Hello",
"styles": map[string]any{
"textColor": "primary",
},
},
}
html := renderInlineContent(content)
if !strings.Contains(html, `class="text-primary"`) {
t.Errorf("expected text color class: %s", html)
}
}
func TestRenderInlineContentWithBackgroundColor(t *testing.T) {
content := []map[string]any{
{
"type": "text",
"text": "Highlighted",
"styles": map[string]any{
"backgroundColor": "hex:#ffcc00",
},
},
}
html := renderInlineContent(content)
if !strings.Contains(html, `bg-[#ffcc00]`) {
t.Errorf("expected background color class: %s", html)
}
}
func TestRenderInlineContentWithBothColors(t *testing.T) {
content := []map[string]any{
{
"type": "text",
"text": "Styled",
"styles": map[string]any{
"textColor": "foreground",
"backgroundColor": "custom:highlight",
},
},
}
html := renderInlineContent(content)
if !strings.Contains(html, `text-foreground`) {
t.Errorf("expected text color class: %s", html)
}
if !strings.Contains(html, `bg-[hsl(var(--color-highlight))]`) {
t.Errorf("expected background color class: %s", html)
}
}
func TestRenderInlineContentWithDefaultColor(t *testing.T) {
content := []map[string]any{
{
"type": "text",
"text": "Normal",
"styles": map[string]any{
"textColor": "default",
},
},
}
html := renderInlineContent(content)
if strings.Contains(html, "class=") {
t.Errorf("default color should not add class: %s", html)
}
}
func TestRenderInlineContentWithColorsAndOtherStyles(t *testing.T) {
content := []map[string]any{
{
"type": "text",
"text": "Bold and colored",
"styles": map[string]any{
"bold": true,
"textColor": "hex:#ff0000",
},
},
}
html := renderInlineContent(content)
if !strings.Contains(html, "<strong>") {
t.Errorf("expected bold tag: %s", html)
}
if !strings.Contains(html, `text-[#ff0000]`) {
t.Errorf("expected text color class: %s", html)
}
}
func TestBlockNoteToHTMLWithColoredParagraph(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{
"type": "text",
"text": "Red text",
"styles": map[string]any{
"textColor": "hex:#ff0000",
},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<p") {
t.Errorf("expected paragraph tag: %s", html)
}
if !strings.Contains(html, `text-[#ff0000]`) {
t.Errorf("expected text color class: %s", html)
}
}
func TestBulletListStringContent(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "bulletListItem",
"content": "Test bullet content",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Test bullet content") {
t.Errorf("expected bullet content: %s", html)
}
}
func TestTableSingleRowDoesNotEmitTbody(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "table",
"content": map[string]any{
"rows": []any{
map[string]any{
"cells": []any{
[]any{map[string]any{"type": "text", "text": "Header"}},
},
},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "<thead>") {
t.Errorf("expected thead: %s", html)
}
if strings.Contains(html, "<tbody>") {
t.Errorf("did not expect tbody for single-row table: %s", html)
}
}
func TestTableCellStringContent(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "table",
"content": map[string]any{
"rows": []any{
map[string]any{"cells": []any{"Header", "Value"}},
map[string]any{"cells": []any{"Row", "Cell"}},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Header") || !strings.Contains(html, "Cell") {
t.Errorf("expected table cell content: %s", html)
}
if !strings.Contains(html, "<tbody>") {
t.Errorf("expected tbody for multi-row table: %s", html)
}
}
func TestNestedListRendering(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "bulletListItem",
"content": "Parent",
"children": []any{
map[string]any{
"type": "bulletListItem",
"content": "Child",
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Parent") || !strings.Contains(html, "Child") {
t.Errorf("expected nested list content: %s", html)
}
if strings.Count(html, "<ul") < 2 {
t.Errorf("expected nested ul elements: %s", html)
}
}
func TestHardBreakInlineContent(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{"type": "text", "text": "Line1"},
map[string]any{"type": "hardBreak"},
map[string]any{"type": "text", "text": "Line2"},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Line1") || !strings.Contains(html, "Line2") || !strings.Contains(html, "<br />") {
t.Errorf("expected hard break in output: %s", html)
}
}
func TestReferencesBlock(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "references",
"props": map[string]any{
"items": `[{"text":"Steve Blank","url":"https://example.com/blank"},{"text":"Charity Majors","url":""}]`,
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `class="bn-references"`) {
t.Errorf("expected bn-references class: %s", html)
}
if !strings.Contains(html, `<a href="https://example.com/blank">Steve Blank`) {
t.Errorf("expected linked reference: %s", html)
}
if !strings.Contains(html, "<li>Charity Majors") {
t.Errorf("expected plain text reference: %s", html)
}
}
func TestReferencesBlockEmpty(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "references",
"props": map[string]any{"items": "[]"},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `class="bn-references"`) {
t.Errorf("expected bn-references wrapper even when empty: %s", html)
}
}
func TestReferencesBlockMalformedJSON(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "references",
"props": map[string]any{"items": "not valid json"},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, `class="bn-references"`) {
t.Errorf("expected graceful fallback: %s", html)
}
}
func TestEmptyDocument(t *testing.T) {
html := BlockNoteToHTML(context.Background(), map[string]any{})
if html != "" {
t.Errorf("expected empty output for empty doc: %s", html)
}
}
func TestUnknownBlockType(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "unknownBlock",
"content": "Some content",
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if !strings.Contains(html, "Some content") {
t.Errorf("expected fallback rendering of unknown block: %s", html)
}
}

View File

@ -8,6 +8,11 @@ type Settings interface {
GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error) GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error)
} }
// Updater allows plugins to modify site settings.
type Updater interface {
UpdateSiteSetting(ctx context.Context, key string, value any) error
}
// GetStringOr returns a string value from a map, or defaultVal if not found/wrong type. // GetStringOr returns a string value from a map, or defaultVal if not found/wrong type.
func GetStringOr(m map[string]any, key, defaultVal string) string { func GetStringOr(m map[string]any, key, defaultVal string) string {
if v, ok := m[key].(string); ok { if v, ok := m[key].(string); ok {