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 { blocks := blocksFromRaw(doc["blocks"]) if len(blocks) == 0 { return "" } return renderBlocks(ctx, blocks) } 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 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": sb.WriteString("

    ") 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 } fmt.Fprintf(&sb, "", level) sb.WriteString(renderInlineContent(content)) if childrenHTML != "" { sb.WriteString(childrenHTML) } fmt.Fprintf(&sb, "\n", level) case "quote": sb.WriteString("
    ") sb.WriteString(renderInlineContent(content)) if childrenHTML != "" { sb.WriteString(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 "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 "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 + "" } } 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() }