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, "%s>\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, ` `, 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(`\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, "%s>", 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, "%s \n", html.EscapeString(url), text)
+ } else {
+ fmt.Fprintf(&sb, "%s \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)
+ }
+}