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("
  • ") sb.WriteString(renderInlineContent(content)) 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)) 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)) 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)) 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)) 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)) 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))
    		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" } fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass) sb.WriteString(renderInlineContent(inlineContentFromRaw(cell))) 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)) 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)) 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) 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 = "" + 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 code, ok := styles["code"].(bool); ok && code { 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)) break } fmt.Fprintf(&sb, ``, html.EscapeString(href)) sb.WriteString(renderInlineContent(linkContent)) sb.WriteString("") case "hardBreak": sb.WriteString("
    ") default: if text != "" { sb.WriteString(html.EscapeString(text)) break } if rawContent, ok := itemMap["content"]; ok { sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent))) } } } return sb.String() }