diff --git a/render/blocknote.go b/render/blocknote.go new file mode 100644 index 0000000..793bd98 --- /dev/null +++ b/render/blocknote.go @@ -0,0 +1,656 @@ +package render + +import ( + "context" + "encoding/json" + "fmt" + "html" + "strings" + + "git.dev.alexdunmow.com/block/pluginsdk/blocks" + "github.com/google/uuid" +) + +// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML. +func BlockNoteToHTML(ctx context.Context, doc map[string]any) string { + rawBlocks := blocksFromRaw(doc["blocks"]) + if len(rawBlocks) == 0 { + return "" + } + return renderBlocks(ctx, rawBlocks) +} + +func renderBlocks(ctx context.Context, blocks []map[string]any) string { + var sb strings.Builder + var currentListType string + var listItems []map[string]any + + flushList := func() { + if len(listItems) == 0 { + return + } + tag := "ul" + listStyle := "list-disc" + if currentListType == "numberedListItem" { + tag = "ol" + listStyle = "list-decimal" + } + fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle) + for _, item := range listItems { + content := inlineContentFromRaw(item["content"]) + childrenHTML := renderChildren(ctx, item["children"]) + sb.WriteString("
  • ") + sb.WriteString(renderInlineContent(content, false)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
  • \n") + } + fmt.Fprintf(&sb, "\n", tag) + listItems = nil + currentListType = "" + } + + for _, blockMap := range blocks { + blockType, _ := blockMap["type"].(string) + + if blockType == "bulletListItem" || blockType == "numberedListItem" { + if currentListType != "" && currentListType != blockType { + flushList() + } + currentListType = blockType + listItems = append(listItems, blockMap) + continue + } + + flushList() + sb.WriteString(renderBlock(ctx, blockMap)) + } + + flushList() + return sb.String() +} + +func inlineContentFromRaw(raw any) []map[string]any { + switch v := raw.(type) { + case []any: + items := make([]map[string]any, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + items = append(items, m) + } + } + return items + case []map[string]any: + return v + case string: + if v == "" { + return nil + } + return []map[string]any{{"type": "text", "text": v}} + default: + return nil + } +} + +func blocksFromRaw(raw any) []map[string]any { + switch v := raw.(type) { + case []any: + items := make([]map[string]any, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + items = append(items, m) + } + } + return items + case []map[string]any: + return v + default: + return nil + } +} + +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) + content := inlineContentFromRaw(block["content"]) + childrenHTML := renderChildren(ctx, block["children"]) + + var sb strings.Builder + + switch blockType { + case "paragraph": + classNames := "my-4" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "

    ", classNames) + sb.WriteString(renderInlineContent(content, false)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("

    \n") + + case "heading": + level := 2 + if l, ok := props["level"].(float64); ok { + level = int(l) + } else if l, ok := props["level"].(int); ok { + level = l + } + if level < 1 { + level = 1 + } + if level > 6 { + level = 6 + } + classNames := "mt-8 mb-4 font-bold" + if alignClass := textAlignClass(props); alignClass != "" { + classNames += " " + alignClass + } + fmt.Fprintf(&sb, "", level, classNames) + sb.WriteString(renderInlineContent(content, false)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + fmt.Fprintf(&sb, "\n", level) + + case "quote": + 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, false)) + 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, `
    `, checkedAttr) + sb.WriteString(renderInlineContent(content, false)) + sb.WriteString("") + if childrenHTML != "" { + fmt.Fprintf(&sb, `
    %s
    `, childrenHTML) + } + sb.WriteString("
    \n") + + case "toggleListItem": + openAttr := "" + if open, ok := props["open"].(bool); ok && open { + openAttr = " open" + } + fmt.Fprintf(&sb, `
    `, openAttr) + sb.WriteString(``) + sb.WriteString(renderInlineContent(content, false)) + sb.WriteString("") + if childrenHTML != "" { + fmt.Fprintf(&sb, `
    %s
    `, childrenHTML) + } + sb.WriteString("
    \n") + + case "codeBlock": + lang := "" + if l, ok := props["language"].(string); ok { + lang = l + } + fmt.Fprintf(&sb, `
    `, html.EscapeString(lang))
    +		sb.WriteString(renderInlineContent(content, false))
    +		sb.WriteString("
    \n") + + case "image": + url := "" + caption := "" + alt := "" + if u, ok := props["url"].(string); ok { + url = u + } + if c, ok := props["caption"].(string); ok { + caption = c + } + if a, ok := props["alt"].(string); ok { + alt = a + } + if alt == "" { + alt = caption + } + sb.WriteString(`
    `) + fmt.Fprintf(&sb, `%s`, html.EscapeString(url), html.EscapeString(alt)) + if caption != "" { + fmt.Fprintf(&sb, "
    %s
    ", html.EscapeString(caption)) + } + sb.WriteString("
    \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(`
    `) + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + if caption != "" { + fmt.Fprintf(&sb, "
    %s
    ", html.EscapeString(caption)) + } + sb.WriteString("
    \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(`
    `) + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + if caption != "" { + fmt.Fprintf(&sb, "
    %s
    ", html.EscapeString(caption)) + } + sb.WriteString("
    \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(`
    `) + if url != "" { + fmt.Fprintf(&sb, ``, html.EscapeString(url)) + sb.WriteString(html.EscapeString(name)) + sb.WriteString("") + } else { + sb.WriteString(html.EscapeString(name)) + } + if caption != "" { + fmt.Fprintf(&sb, `

    %s

    `, html.EscapeString(caption)) + } + sb.WriteString("
    \n") + + case "table": + sb.WriteString(`
    `) + if tableContent, ok := block["content"].(map[string]any); ok { + if rows, ok := tableContent["rows"].([]any); ok && len(rows) > 0 { + renderTableRow := func(row any, isHeader bool) { + if rowMap, ok := row.(map[string]any); ok { + if cells, ok := rowMap["cells"].([]any); ok { + for _, cell := range cells { + cellTag := "td" + cellClass := "px-4 py-2 text-sm" + if isHeader { + cellTag = "th" + cellClass = "px-4 py-3 text-left text-sm font-semibold" + } + // BlockNote >=0.15 wraps each cell as a tableCell object + // {type:"tableCell", content:[...]}; older docs store the + // cell's inline content (string or array) directly. + cellContent := cell + if cellMap, ok := cell.(map[string]any); ok { + if t, _ := cellMap["type"].(string); t == "tableCell" { + cellContent = cellMap["content"] + } + } + fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass) + sb.WriteString(renderInlineContent(inlineContentFromRaw(cellContent), false)) + fmt.Fprintf(&sb, "", cellTag) + } + } + } + } + sb.WriteString("") + renderTableRow(rows[0], true) + sb.WriteString("") + if len(rows) > 1 { + sb.WriteString("") + for _, row := range rows[1:] { + sb.WriteString("") + renderTableRow(row, false) + sb.WriteString("") + } + sb.WriteString("") + } + } + } + sb.WriteString("
    \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("
    \n") + if len(content) > 0 { + sb.WriteString("

    ") + sb.WriteString(renderInlineContent(content, false)) + sb.WriteString("

    \n") + } + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
    \n") + + case "humanProof": + if hp := blocks.GetHumanProofBanner(ctx); hp != nil { + sb.WriteString(blocks.RenderHumanProofBanner(hp)) + sb.WriteByte('\n') + } + + case "references": + sb.WriteString("
    \n") + sb.WriteString("
    References
    \n") + sb.WriteString("
      \n") + if itemsJSON, ok := props["items"].(string); ok && itemsJSON != "" { + var items []map[string]string + if err := json.Unmarshal([]byte(itemsJSON), &items); err == nil { + for _, item := range items { + text := html.EscapeString(item["text"]) + url := item["url"] + if text == "" { + continue + } + if url != "" { + fmt.Fprintf(&sb, "
    1. %s
    2. \n", html.EscapeString(url), text) + } else { + fmt.Fprintf(&sb, "
    3. %s
    4. \n", text) + } + } + } + } + sb.WriteString("
    \n
    \n") + + default: + if len(content) > 0 || childrenHTML != "" { + sb.WriteString("
    ") + sb.WriteString(renderInlineContent(content, false)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
    \n") + } + } + + return sb.String() +} + +func renderChildren(ctx context.Context, children any) string { + blocks := blocksFromRaw(children) + if len(blocks) == 0 { + return "" + } + return renderBlocks(ctx, blocks) +} + +func renderInlineContent(content []map[string]any, insideLink bool) string { + var sb strings.Builder + for _, itemMap := range content { + itemType, _ := itemMap["type"].(string) + text, _ := itemMap["text"].(string) + styles, _ := itemMap["styles"].(map[string]any) + + switch itemType { + case "text": + isCode := false + if styles != nil { + if c, ok := styles["code"].(bool); ok && c { + isCode = true + } + } + var rendered string + if insideLink || isCode { + rendered = html.EscapeString(text) + } else { + rendered = autolinkText(text) + } + if styles != nil { + if bold, ok := styles["bold"].(bool); ok && bold { + rendered = "" + rendered + "" + } + if italic, ok := styles["italic"].(bool); ok && italic { + rendered = "" + rendered + "" + } + if underline, ok := styles["underline"].(bool); ok && underline { + rendered = "" + rendered + "" + } + if strike, ok := styles["strike"].(bool); ok && strike { + rendered = "" + rendered + "" + } + if strike, ok := styles["strikethrough"].(bool); ok && strike { + rendered = "" + rendered + "" + } + if isCode { + rendered = "" + 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) + + case "link": + href, _ := itemMap["href"].(string) + linkContent := inlineContentFromRaw(itemMap["content"]) + if href == "" { + sb.WriteString(renderInlineContent(linkContent, insideLink)) + break + } + fmt.Fprintf(&sb, ``, html.EscapeString(href)) + sb.WriteString(renderInlineContent(linkContent, true)) + sb.WriteString("") + + case "hardBreak": + sb.WriteString("
    ") + + default: + if text != "" { + if insideLink { + sb.WriteString(html.EscapeString(text)) + } else { + sb.WriteString(autolinkText(text)) + } + break + } + if rawContent, ok := itemMap["content"]; ok { + sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink)) + } + } + } + return sb.String() +} + +// autolinkText escapes plain text for HTML output and wraps any https:// URL +// substring in URL. Bare domains, www. and +// http:// URLs are intentionally not auto-linked. +// +// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL. +// Closing parens, brackets and braces are kept inside the URL only when +// balanced with an opener inside the URL itself — so +// "(see https://example.com)" links only "https://example.com" +// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren. +func autolinkText(text string) string { + const scheme = "https://" + var sb strings.Builder + rest := text + for { + idx := strings.Index(rest, scheme) + if idx < 0 { + sb.WriteString(html.EscapeString(rest)) + return sb.String() + } + sb.WriteString(html.EscapeString(rest[:idx])) + + // Scan forward until whitespace or a URL-terminating delimiter. + end := idx + len(scheme) + for end < len(rest) { + c := rest[end] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '<' || c == '>' || c == '"' || c == '\'' { + break + } + end++ + } + // Walk back over trailing characters that should sit outside the link, + // preserving paren/bracket/brace balance. + urlEnd := end + for urlEnd > idx+len(scheme) { + last := rest[urlEnd-1] + if last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?' || last == '"' || last == '\'' { + urlEnd-- + continue + } + candidate := rest[idx:urlEnd] + if last == ')' && strings.Count(candidate, ")") > strings.Count(candidate, "(") { + urlEnd-- + continue + } + if last == ']' && strings.Count(candidate, "]") > strings.Count(candidate, "[") { + urlEnd-- + continue + } + if last == '}' && strings.Count(candidate, "}") > strings.Count(candidate, "{") { + urlEnd-- + continue + } + break + } + url := rest[idx:urlEnd] + tail := rest[urlEnd:end] + + if url == scheme { + sb.WriteString(html.EscapeString(rest[idx:end])) + } else { + escaped := html.EscapeString(url) + sb.WriteString(``) + sb.WriteString(escaped) + sb.WriteString(``) + if tail != "" { + sb.WriteString(html.EscapeString(tail)) + } + } + + rest = rest[end:] + } +} diff --git a/render/blocknote_autolink_test.go b/render/blocknote_autolink_test.go new file mode 100644 index 0000000..7cf2e77 --- /dev/null +++ b/render/blocknote_autolink_test.go @@ -0,0 +1,229 @@ +package render + +import ( + "context" + "strings" + "testing" +) + +func TestAutolinkText_PlainTextPassthrough(t *testing.T) { + got := autolinkText("just some prose with no link") + want := "just some prose with no link" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAutolinkText_HTMLMetacharactersEscaped(t *testing.T) { + got := autolinkText("a < b && c > d") + if !strings.Contains(got, "<") || !strings.Contains(got, ">") || !strings.Contains(got, "&") { + t.Errorf("expected escaped metacharacters, got %q", got) + } + if strings.Contains(got, " tag in plain prose: %q", got) + } +} + +func TestAutolinkText_SingleHTTPSURL(t *testing.T) { + got := autolinkText("see https://example.com for more") + want := `see https://example.com for more` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAutolinkText_HTTPNotLinked(t *testing.T) { + got := autolinkText("see http://example.com for more") + if strings.Contains(got, "https://example.com.` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAutolinkText_TrailingPunctuationStripped(t *testing.T) { + cases := []struct { + input string + wantTail string + }{ + {"check https://example.com,", ","}, + {"is it https://example.com?", "?"}, + {"wow https://example.com!", "!"}, + {"so https://example.com;", ";"}, + {"foo https://example.com:", ":"}, + {`he said "https://example.com"`, `"`}, + {"foo https://example.com'", "'"}, + } + for _, tc := range cases { + got := autolinkText(tc.input) + if !strings.Contains(got, `https://example.com`) { + t.Errorf("input %q: expected URL link without trailing punctuation, got %q", tc.input, got) + } + if !strings.HasSuffix(got, tc.wantTail) { + t.Errorf("input %q: expected suffix %q, got %q", tc.input, tc.wantTail, got) + } + } +} + +func TestAutolinkText_ClosingParenOutsideAnchorWhenUnbalanced(t *testing.T) { + got := autolinkText("(see https://example.com)") + want := `(see https://example.com)` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAutolinkText_ClosingParenKeptWhenBalanced(t *testing.T) { + got := autolinkText("see https://en.wikipedia.org/wiki/Foo_(bar) page") + if !strings.Contains(got, `https://en.wikipedia.org/wiki/Foo_(bar)`) { + t.Errorf("expected paren kept inside anchor when balanced, got %q", got) + } +} + +func TestAutolinkText_MultipleURLs(t *testing.T) { + got := autolinkText("first https://a.com then https://b.com end") + if strings.Count(got, "https://example.com`) { + t.Errorf("expected anchor at start, got %q", got) + } +} + +func TestAutolinkText_URLAtStringEnd(t *testing.T) { + got := autolinkText("checkout https://example.com") + want := `checkout https://example.com` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// Integration tests: confirm autolinking flows through the public renderer + +func TestBlockNoteToHTML_AutolinksURLInParagraph(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "visit https://example.com today"}, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, `https://example.com`) { + t.Errorf("expected autolinked URL in paragraph, got %s", html) + } +} + +func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "link", + "href": "https://short.url/", + "content": []any{ + map[string]any{"type": "text", "text": "see https://full-url-text.com"}, + }, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + // Outer link should exist + if !strings.Contains(html, ``) { + t.Errorf("expected outer explicit link, got %s", html) + } + // Inner text must NOT become a nested anchor + if strings.Contains(html, `") || !strings.Contains(html, "https://example.com") { + t.Errorf("expected code-wrapped literal URL, got %s", html) + } +} + +func TestBlockNoteToHTML_BoldWrapsAutolink(t *testing.T) { + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "https://example.com", + "styles": map[string]any{"bold": true}, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + if !strings.Contains(html, `https://example.com`) { + t.Errorf("expected bold-wrapped autolink, got %s", html) + } +} diff --git a/render/blocknote_test.go b/render/blocknote_test.go new file mode 100644 index 0000000..a3dfb75 --- /dev/null +++ b/render/blocknote_test.go @@ -0,0 +1,674 @@ +package render + +import ( + "context" + "strings" + "testing" + + "git.dev.alexdunmow.com/block/pluginsdk/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, "Bold text") { + 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, false) + + 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, false) + + 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, false) + + 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, false) + + 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, false) + + if !strings.Contains(html, "") { + 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, "") { + t.Errorf("expected thead: %s", html) + } + if strings.Contains(html, "") { + 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, "") { + t.Errorf("expected tbody for multi-row table: %s", html) + } +} + +// BlockNote >=0.15 (the editor ships 0.47) stores each table cell as a +// tableCell object: {type:"tableCell", props:{...}, content:[...inline]}. +// The renderer must unwrap content[] from these objects, not just bare +// inline arrays/strings. +func TestTableCellObjectContent(t *testing.T) { + cell := func(text string) map[string]any { + return map[string]any{ + "type": "tableCell", + "props": map[string]any{}, + "content": []any{map[string]any{"type": "text", "text": text}}, + } + } + doc := map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "table", + "content": map[string]any{ + "type": "tableContent", + "rows": []any{ + map[string]any{"cells": []any{cell("Header A"), cell("Header B")}}, + map[string]any{"cells": []any{cell("Cell A"), cell("Cell B")}}, + }, + }, + }, + }, + } + html := BlockNoteToHTML(context.Background(), doc) + for _, want := range []string{"Header A", "Header B", "Cell A", "Cell B"} { + if !strings.Contains(html, want) { + t.Errorf("expected table cell content %q in output: %s", want, 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, "") { + 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, "
  • 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) + } +}