diff --git a/content/content.go b/content/content.go index 5c6b472..a83cc9e 100644 --- a/content/content.go +++ b/content/content.go @@ -35,7 +35,7 @@ type PostInfo struct { } // Content provides content access for plugins. -// The CMS implements this interface and wires it into ServiceDeps. +// The CMS implements this interface and wires it into CoreServices. type Content interface { GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error) GetPage(ctx context.Context, slug string) (*PageInfo, error) diff --git a/datasources/datasources.go b/datasources/datasources.go new file mode 100644 index 0000000..8bbd5e7 --- /dev/null +++ b/datasources/datasources.go @@ -0,0 +1,21 @@ +package datasources + +import ( + "context" + + "github.com/google/uuid" +) + +// Datasources provides bucket/datasource access for plugins. +// The CMS implements this interface and wires it into CoreServices. +type Datasources interface { + ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*Result, error) + ResolveBucketByKey(ctx context.Context, bucketKey string) (*Result, error) +} + +// Result is the output from resolving a bucket. +type Result struct { + Items []any `json:"items"` + Total int `json:"total"` + Meta map[string]any `json:"meta,omitempty"` +} diff --git a/plugin/deps.go b/plugin/deps.go index 594374f..6a5478c 100644 --- a/plugin/deps.go +++ b/plugin/deps.go @@ -8,6 +8,7 @@ import ( "git.dev.alexdunmow.com/block/core/auth" "git.dev.alexdunmow.com/block/core/content" "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/menus" "git.dev.alexdunmow.com/block/core/settings" @@ -15,14 +16,15 @@ import ( "github.com/google/uuid" ) -// ServiceDeps provides dependencies that plugins need for RPC service handlers. -type ServiceDeps struct { +// CoreServices provides CMS capabilities to plugins. +type CoreServices struct { // Capability interfaces — typed access to CMS functionality Content content.Content Settings settings.Settings Gating gating.Gating Crypto crypto.Crypto Menus menus.Menus + Datasources datasources.Datasources PublicUsers auth.PublicUsers Subscriptions subscriptions.Subscriptions diff --git a/plugin/registration.go b/plugin/registration.go index cb8cf02..7935804 100644 --- a/plugin/registration.go +++ b/plugin/registration.go @@ -27,19 +27,19 @@ type PluginRegistration struct { BundledFonts func() []byte MasterPages func() []MasterPageDefinition - HTTPHandler func(deps ServiceDeps) http.Handler + HTTPHandler func(deps CoreServices) http.Handler SettingsPanel func() string AdminPages func() []AdminPage CSSManifest func() *CSSManifest - ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error) - JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc + ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error) + JobHandlers func(deps CoreServices) map[string]JobHandlerFunc AIActions func() []AIAction DirectoryExtensions func() *DirectoryExtensions MediaHooks MediaHooksProvider - Load func(deps ServiceDeps) error + Load func(deps CoreServices) error Unload func(ctx context.Context) error Dependencies []Dependency diff --git a/render/blocknote.go b/render/blocknote.go index 1828da3..62e5002 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -13,11 +13,11 @@ import ( // BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML. func BlockNoteToHTML(ctx context.Context, doc map[string]any) string { - blocks := blocksFromRaw(doc["blocks"]) - if len(blocks) == 0 { + rawBlocks := blocksFromRaw(doc["blocks"]) + if len(rawBlocks) == 0 { return "" } - return renderBlocks(ctx, blocks) + return renderBlocks(ctx, rawBlocks) } func renderBlocks(ctx context.Context, blocks []map[string]any) string { @@ -110,6 +110,75 @@ func blocksFromRaw(raw any) []map[string]any { } } +func textAlignClass(props map[string]any) string { + if props == nil { + return "" + } + align, _ := props["textAlignment"].(string) + switch align { + case "left": + return "text-left" + case "center": + return "text-center" + case "right": + return "text-right" + case "justify": + return "text-justify" + default: + return "" + } +} + +func safeClassToken(value string) string { + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return "" + } + return value +} + +func isHexDigit(c rune) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +func colorValueToClass(value string, prefix string) string { + if value == "" || value == "default" { + return "" + } + + if after, ok := strings.CutPrefix(value, "hex:"); ok { + hex := after + if len(hex) != 7 && len(hex) != 4 { + return "" + } + for i, r := range hex { + if i == 0 && r == '#' { + continue + } + if !isHexDigit(r) { + return "" + } + } + return fmt.Sprintf("%s-[%s]", prefix, hex) + } + + if after, ok := strings.CutPrefix(value, "custom:"); ok { + name := safeClassToken(after) + if name == "" { + return "" + } + return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name) + } + + value = safeClassToken(value) + if value == "" { + return "" + } + return fmt.Sprintf("%s-%s", prefix, value) +} + func renderBlock(ctx context.Context, block map[string]any) string { blockType, _ := block["type"].(string) props, _ := block["props"].(map[string]any) @@ -120,7 +189,11 @@ func renderBlock(ctx context.Context, block map[string]any) string { switch blockType { case "paragraph": - sb.WriteString("
") + classNames := "my-4" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "
", classNames)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
@@ -140,7 +213,11 @@ func renderBlock(ctx context.Context, block map[string]any) string {
if level > 6 {
level = 6
}
- fmt.Fprintf(&sb, " %s ")
+ sb.WriteString(renderInlineContent(content))
+ sb.WriteString(" ") {
+ t.Errorf("expected thead: %s", html)
+ }
+ if strings.Contains(html, "")
+ classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
+ if alignClass := textAlignClass(props); alignClass != "" {
+ classNames += " " + alignClass
+ }
+ fmt.Fprintf(&sb, "
", classNames)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("
\n")
+ case "checkListItem":
+ checked := false
+ if c, ok := props["checked"].(bool); ok {
+ checked = c
+ }
+ checkedAttr := ""
+ if checked {
+ checkedAttr = " checked"
+ }
+ fmt.Fprintf(&sb, ``)
+ sb.WriteString(renderInlineContent(content))
+ sb.WriteString("
")
+ if childrenHTML != "" {
+ fmt.Fprintf(&sb, ``)
if tableContent, ok := block["content"].(map[string]any); ok {
@@ -245,6 +418,18 @@ func renderBlock(ctx context.Context, block map[string]any) string {
}
}
+ case "statement":
+ sb.WriteString("
" + rendered + ""
}
+
+ 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(`%s`, strings.Join(colorClasses, " "), rendered)
+ }
}
sb.WriteString(rendered)
diff --git a/render/blocknote_test.go b/render/blocknote_test.go
index 5ffdf29..97c32bb 100644
--- a/render/blocknote_test.go
+++ b/render/blocknote_test.go
@@ -46,3 +46,595 @@ func TestBlockNoteToHTMLUsesSDKContextResolvers(t *testing.T) {
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, "") {
+ 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, `Steve Blank`) {
+ t.Errorf("expected linked reference: %s", html)
+ }
+ if !strings.Contains(html, "