Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

16 changed files with 26 additions and 1259 deletions

View File

@ -1,50 +0,0 @@
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

@ -1,42 +0,0 @@
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,7 +6,5 @@ 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 CoreServices. // The CMS implements this interface and wires it into ServiceDeps.
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(ctx context.Context, doc map[string]any) string BlockNoteToHTML(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

@ -1,21 +0,0 @@
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,7 +2,6 @@ package plugin
import ( import (
"io/fs" "io/fs"
"strings"
"git.dev.alexdunmow.com/block/core/blocks" "git.dev.alexdunmow.com/block/core/blocks"
) )
@ -19,25 +18,15 @@ 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.RegisterTemplateOverrideWithSource(templateKey, blockKey, r.prefix, fn) r.inner.RegisterTemplateOverride(templateKey, blockKey, 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.LoadSchemasFromFSWithPrefix(fsys, r.prefix) return r.inner.LoadSchemasFromFS(fsys)
}
func (r *PluginBlockRegistry) LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error {
return r.inner.LoadSchemasFromFSWithPrefix(fsys, prefix)
} }

View File

@ -1,70 +0,0 @@
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,13 +0,0 @@
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,7 +8,6 @@ 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"
@ -16,15 +15,14 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// CoreServices provides CMS capabilities to plugins. // ServiceDeps provides dependencies that plugins need for RPC service handlers.
type CoreServices struct { type ServiceDeps 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
@ -48,12 +46,6 @@ type CoreServices 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
@ -89,10 +81,3 @@ 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 any) error EnsureSetting(key string, defaultValue map[string]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 any) error UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value map[string]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,7 +1,6 @@
package plugin package plugin
import ( import (
"context"
"io/fs" "io/fs"
"net/http" "net/http"
@ -27,21 +26,18 @@ type PluginRegistration struct {
BundledFonts func() []byte BundledFonts func() []byte
MasterPages func() []MasterPageDefinition MasterPages func() []MasterPageDefinition
HTTPHandler func(deps CoreServices) http.Handler HTTPHandler func(deps ServiceDeps) http.Handler
SettingsPanel func() string SettingsPanel func() string
AdminPages func() []AdminPage AdminPages func() []AdminPage
CSSManifest func() *CSSManifest CSSManifest func() *CSSManifest
ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error) ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error)
JobHandlers func(deps CoreServices) map[string]JobHandlerFunc JobHandlers func(deps ServiceDeps) 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

View File

@ -1,23 +0,0 @@
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
}

View File

@ -1,105 +0,0 @@
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,26 +1,22 @@
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(ctx context.Context, doc map[string]any) string { func BlockNoteToHTML(doc map[string]any) string {
rawBlocks := blocksFromRaw(doc["blocks"]) blocks := blocksFromRaw(doc["blocks"])
if len(rawBlocks) == 0 { if len(blocks) == 0 {
return "" return ""
} }
return renderBlocks(ctx, rawBlocks) return renderBlocks(blocks)
} }
func renderBlocks(ctx context.Context, blocks []map[string]any) string { func renderBlocks(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
@ -38,7 +34,7 @@ func renderBlocks(ctx context.Context, 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(ctx, item["children"]) childrenHTML := renderChildren(item["children"])
sb.WriteString("<li>") sb.WriteString("<li>")
sb.WriteString(renderInlineContent(content)) sb.WriteString(renderInlineContent(content))
if childrenHTML != "" { if childrenHTML != "" {
@ -64,7 +60,7 @@ func renderBlocks(ctx context.Context, blocks []map[string]any) string {
} }
flushList() flushList()
sb.WriteString(renderBlock(ctx, blockMap)) sb.WriteString(renderBlock(blockMap))
} }
flushList() flushList()
@ -110,90 +106,17 @@ func blocksFromRaw(raw any) []map[string]any {
} }
} }
func textAlignClass(props map[string]any) string { func renderBlock(block 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(ctx, block["children"]) childrenHTML := renderChildren(block["children"])
var sb strings.Builder var sb strings.Builder
switch blockType { switch blockType {
case "paragraph": case "paragraph":
classNames := "my-4" sb.WriteString("<p class=\"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)
@ -213,11 +136,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
if level > 6 { if level > 6 {
level = 6 level = 6
} }
classNames := "mt-8 mb-4 font-bold" fmt.Fprintf(&sb, "<h%d class=\"mt-8 mb-4 font-bold\">", level)
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)
@ -225,48 +144,13 @@ func renderBlock(ctx context.Context, block map[string]any) string {
fmt.Fprintf(&sb, "</h%d>\n", level) fmt.Fprintf(&sb, "</h%d>\n", level)
case "quote": case "quote":
classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground" sb.WriteString("<blockquote class=\"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 {
@ -299,67 +183,6 @@ func renderBlock(ctx context.Context, 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 {
@ -397,45 +220,6 @@ func renderBlock(ctx context.Context, 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")
@ -473,12 +257,12 @@ func renderBlock(ctx context.Context, block map[string]any) string {
return sb.String() return sb.String()
} }
func renderChildren(ctx context.Context, children any) string { func renderChildren(children any) string {
blocks := blocksFromRaw(children) blocks := blocksFromRaw(children)
if len(blocks) == 0 { if len(blocks) == 0 {
return "" return ""
} }
return renderBlocks(ctx, blocks) return renderBlocks(blocks)
} }
func renderInlineContent(content []map[string]any) string { func renderInlineContent(content []map[string]any) string {
@ -510,22 +294,6 @@ 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)

View File

@ -1,640 +0,0 @@
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,11 +8,6 @@ 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 {