pluginsdk/render/blocknote.go
Alex Dunmow 25bcbad081 feat(render): image block layout, link, and live media attribution
MediaValue gains an Attribution credit resolved through the existing
MediaResolver context hook. The BlockNote image case renders width and
align presets, alt text, an optional link wrap, and the attribution chip
in chip, chipHover, and below modes with the same themed markup as the
cms page-builder image block. Legacy url-and-caption blocks render
byte-identical to the previous output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:51:18 +08:00

770 lines
21 KiB
Go

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("<li>")
sb.WriteString(renderInlineContent(content, false))
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, false))
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, false))
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, false))
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, false))
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, false))
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, false))
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
}
link := ""
if l, ok := props["link"].(string); ok {
link = l
}
width := ""
if w, ok := props["width"].(string); ok {
width = w
}
align := ""
if a, ok := props["align"].(string); ok {
align = a
}
attMode := ""
if m, ok := props["attributionMode"].(string); ok {
attMode = m
}
var att *blocks.MediaAttribution
if attMode == "chip" || attMode == "chipHover" || attMode == "below" {
if idStr, ok := props["mediaId"].(string); ok && idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
if resolver := blocks.GetMediaResolver(ctx); resolver != nil {
if mv := resolver.ResolveMedia(ctx, id); mv != nil {
att = mv.Attribution
}
}
}
}
}
chip := ""
switch attMode {
case "chip":
chip = buildAttributionHTML(att, imageChipClass)
case "chipHover":
chip = buildAttributionHTML(att, imageChipClass+" opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity")
}
figureClass := "my-6"
switch width {
case "small":
figureClass += " max-w-[400px]"
case "medium":
figureClass += " max-w-[600px]"
case "wide":
figureClass += " max-w-[800px]"
}
if width == "small" || width == "medium" || width == "wide" {
switch align {
case "left":
figureClass += " float-left mr-6 mb-4"
case "right":
figureClass += " float-right ml-6 mb-4"
default:
figureClass += " mx-auto"
}
}
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if link != "" {
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
}
// The positioning wrapper only exists when a chip is rendered, so
// legacy and chip-less blocks keep today's exact markup.
if chip != "" {
wrapClass := "relative overflow-hidden"
if attMode == "chipHover" {
wrapClass = "group " + wrapClass
}
img = fmt.Sprintf(`<div class="%s">%s%s</div>`, wrapClass, img, chip)
}
fmt.Fprintf(&sb, `<figure class="%s">`, figureClass)
sb.WriteString(img)
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
if attMode == "below" {
if credit := buildAttributionHTML(att, ""); credit != "" {
fmt.Fprintf(&sb, `<div class="mt-1 text-xs text-muted-foreground text-center">%s</div>`, credit)
}
}
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"
}
// 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("<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, false))
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, false))
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, 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 = "<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 isCode {
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, insideLink))
break
}
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
sb.WriteString(renderInlineContent(linkContent, true))
sb.WriteString("</a>")
case "hardBreak":
sb.WriteString("<br />")
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()
}
// imageChipClass matches the page-builder image block's attribution chip
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
// blog and page credits identically.
const imageChipClass = "absolute bottom-2 right-2 z-10 px-2 py-1 rounded-md bg-background/80 backdrop-blur-sm text-xs text-muted-foreground"
// buildAttributionHTML renders the escaped "Photo by NAME on SOURCE" credit,
// or "" when there is no photographer to credit. The "on SOURCE" suffix is
// omitted when no source is set.
func buildAttributionHTML(att *blocks.MediaAttribution, class string) string {
if att == nil || att.Photographer == "" {
return ""
}
var b strings.Builder
b.WriteString(`<span`)
if class != "" {
fmt.Fprintf(&b, ` class="%s"`, html.EscapeString(class))
}
b.WriteString(`>Photo by `)
writeAttributionLink(&b, att.PhotographerURL, att.Photographer)
if att.Source != "" {
sourceLabel := strings.ToUpper(att.Source[:1]) + att.Source[1:]
b.WriteString(` on `)
writeAttributionLink(&b, att.SourceURL, sourceLabel)
}
b.WriteString(`</span>`)
return b.String()
}
func writeAttributionLink(b *strings.Builder, href, label string) {
if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) {
fmt.Fprintf(b, `<a href="%s" target="_blank" rel="noopener noreferrer" class="underline">%s</a>`,
html.EscapeString(href), html.EscapeString(label))
return
}
b.WriteString(html.EscapeString(label))
}
// autolinkText escapes plain text for HTML output and wraps any https:// URL
// substring in <a href="..." rel="noopener">URL</a>. 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(`<a href="`)
sb.WriteString(escaped)
sb.WriteString(`" rel="noopener">`)
sb.WriteString(escaped)
sb.WriteString(`</a>`)
if tail != "" {
sb.WriteString(html.EscapeString(tail))
}
}
rest = rest[end:]
}
}