Compare commits
No commits in common. "main" and "v0.3.0" have entirely different histories.
50
Makefile
50
Makefile
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
105
plugin/seeder.go
105
plugin/seeder.go
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user