core/render/blocknote.go
Alex Dunmow 7eb3e27053 feat: converge BlockNote renderer, add datasources bridge, rename ServiceDeps to CoreServices
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>
2026-05-03 10:18:32 +08:00

558 lines
14 KiB
Go

package render
import (
"context"
"encoding/json"
"fmt"
"html"
"strings"
"git.dev.alexdunmow.com/block/core/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("<li>")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</li>\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, "<p class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</p>\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, "<h%d class=\"%s\">", level, classNames)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
fmt.Fprintf(&sb, "</h%d>\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, "<blockquote class=\"%s\">", classNames)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</blockquote>\n")
case "checkListItem":
checked := false
if c, ok := props["checked"].(bool); ok {
checked = c
}
checkedAttr := ""
if checked {
checkedAttr = " checked"
}
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</span>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
}
sb.WriteString("</div>\n")
case "toggleListItem":
openAttr := ""
if open, ok := props["open"].(bool); ok && open {
openAttr = " open"
}
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
sb.WriteString(renderInlineContent(content))
sb.WriteString("</summary>")
if childrenHTML != "" {
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
}
sb.WriteString("</details>\n")
case "codeBlock":
lang := ""
if l, ok := props["language"].(string); ok {
lang = l
}
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
sb.WriteString(renderInlineContent(content))
sb.WriteString("</code></pre>\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(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "video":
url := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if c, ok := props["caption"].(string); ok {
caption = c
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "audio":
url := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if c, ok := props["caption"].(string); ok {
caption = c
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "file":
url := ""
name := ""
caption := ""
if u, ok := props["url"].(string); ok {
url = u
}
if n, ok := props["name"].(string); ok {
name = n
}
if c, ok := props["caption"].(string); ok {
caption = c
}
if name == "" {
name = url
}
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
if url != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>")
} else {
sb.WriteString(html.EscapeString(name))
}
if caption != "" {
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
}
sb.WriteString("</div>\n")
case "table":
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
if tableContent, ok := block["content"].(map[string]any); ok {
if 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"
}
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell)))
fmt.Fprintf(&sb, "</%s>", cellTag)
}
}
}
}
sb.WriteString("<thead><tr class=\"bg-muted\">")
renderTableRow(rows[0], true)
sb.WriteString("</tr></thead>")
if len(rows) > 1 {
sb.WriteString("<tbody>")
for _, row := range rows[1:] {
sb.WriteString("<tr class=\"border-b border-border\">")
renderTableRow(row, false)
sb.WriteString("</tr>")
}
sb.WriteString("</tbody>")
}
}
}
sb.WriteString("</table></div>\n")
case "embed":
if resolver := blocks.GetEmbedResolver(ctx); resolver != nil {
blockID := ""
if v, ok := props["blockId"].(string); ok {
blockID = v
}
dataSource := ""
if v, ok := props["dataSource"].(string); ok {
dataSource = v
}
layout := "full"
if v, ok := props["layout"].(string); ok && v != "" {
layout = v
}
if blockID != "" {
if parsedID, err := uuid.Parse(blockID); err == nil {
sb.WriteString(resolver.RenderEmbed(ctx, parsedID, dataSource, layout))
}
}
}
case "statement":
sb.WriteString("<div class=\"bn-statement\">\n")
if len(content) > 0 {
sb.WriteString("<p>")
sb.WriteString(renderInlineContent(content))
sb.WriteString("</p>\n")
}
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</div>\n")
case "humanProof":
if hp := blocks.GetHumanProofBanner(ctx); hp != nil {
sb.WriteString(blocks.RenderHumanProofBanner(hp))
sb.WriteByte('\n')
}
case "references":
sb.WriteString("<div class=\"bn-references\">\n")
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
sb.WriteString("<ol>\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, "<li><a href=\"%s\">%s</a></li>\n", html.EscapeString(url), text)
} else {
fmt.Fprintf(&sb, "<li>%s</li>\n", text)
}
}
}
}
sb.WriteString("</ol>\n</div>\n")
default:
if len(content) > 0 || childrenHTML != "" {
sb.WriteString("<div>")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</div>\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) 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":
rendered := html.EscapeString(text)
if styles != nil {
if bold, ok := styles["bold"].(bool); ok && bold {
rendered = "<strong>" + rendered + "</strong>"
}
if italic, ok := styles["italic"].(bool); ok && italic {
rendered = "<em>" + rendered + "</em>"
}
if underline, ok := styles["underline"].(bool); ok && underline {
rendered = "<u>" + rendered + "</u>"
}
if strike, ok := styles["strike"].(bool); ok && strike {
rendered = "<s>" + rendered + "</s>"
}
if strike, ok := styles["strikethrough"].(bool); ok && strike {
rendered = "<s>" + rendered + "</s>"
}
if code, ok := styles["code"].(bool); ok && code {
rendered = "<code>" + rendered + "</code>"
}
var colorClasses []string
if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" {
if class := colorValueToClass(textColor, "text"); class != "" {
colorClasses = append(colorClasses, class)
}
}
if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" {
if class := colorValueToClass(bgColor, "bg"); class != "" {
colorClasses = append(colorClasses, class)
}
}
if len(colorClasses) > 0 {
rendered = fmt.Sprintf(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
}
}
sb.WriteString(rendered)
case "link":
href, _ := itemMap["href"].(string)
linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" {
sb.WriteString(renderInlineContent(linkContent))
break
}
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
sb.WriteString(renderInlineContent(linkContent))
sb.WriteString("</a>")
case "hardBreak":
sb.WriteString("<br />")
default:
if text != "" {
sb.WriteString(html.EscapeString(text))
break
}
if rawContent, ok := itemMap["content"]; ok {
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent)))
}
}
}
return sb.String()
}