SDK renderer now has full feature parity with the host: text alignment, checkListItem, toggleListItem, video, audio, file, statement blocks, and text/background color inline styles. New datasources.Datasources interface lets plugins resolve buckets directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
641 lines
16 KiB
Go
641 lines
16 KiB
Go
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)
|
|
}
|
|
}
|