feat(render): BlockNote renderer moves in from block/core
Copy core@v0.20.3 render/blocknote.go (+ tests) with the blocks import repointed to pluginsdk/blocks, so GetEmbedResolver/GetHumanProofBanner read the same context keys the pluginsdk bridge writes. Fixes empty embeds and missing human-proof banners in hosts that inject render state via pluginsdk/blocks context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
52f6c62fd3
commit
54716d705f
656
render/blocknote.go
Normal file
656
render/blocknote.go
Normal file
@ -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("<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
|
||||
}
|
||||
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"
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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:]
|
||||
}
|
||||
}
|
||||
229
render/blocknote_autolink_test.go
Normal file
229
render/blocknote_autolink_test.go
Normal file
@ -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, "<a ") {
|
||||
t.Errorf("did not expect <a> tag in plain prose: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_SingleHTTPSURL(t *testing.T) {
|
||||
got := autolinkText("see https://example.com for more")
|
||||
want := `see <a href="https://example.com" rel="noopener">https://example.com</a> 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, "<a ") {
|
||||
t.Errorf("http:// should not be auto-linked, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "http://example.com") {
|
||||
t.Errorf("expected URL to appear as plain escaped text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_BareDomainNotLinked(t *testing.T) {
|
||||
got := autolinkText("see example.com for more")
|
||||
if strings.Contains(got, "<a ") {
|
||||
t.Errorf("bare domain should not be auto-linked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_TrailingPeriodOutsideAnchor(t *testing.T) {
|
||||
got := autolinkText("visit https://example.com.")
|
||||
want := `visit <a href="https://example.com" rel="noopener">https://example.com</a>.`
|
||||
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, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
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 <a href="https://example.com" rel="noopener">https://example.com</a>)`
|
||||
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, `<a href="https://en.wikipedia.org/wiki/Foo_(bar)" rel="noopener">https://en.wikipedia.org/wiki/Foo_(bar)</a>`) {
|
||||
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, "<a ") != 2 {
|
||||
t.Errorf("expected 2 anchors, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `href="https://a.com"`) || !strings.Contains(got, `href="https://b.com"`) {
|
||||
t.Errorf("expected both URLs linked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLWithQueryStringContainingAmpersand(t *testing.T) {
|
||||
got := autolinkText("see https://example.com/?a=1&b=2 ok")
|
||||
// `&` should be escaped to `&` in both href and text
|
||||
if !strings.Contains(got, `href="https://example.com/?a=1&b=2"`) {
|
||||
t.Errorf("expected escaped ampersand in href, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "rel=\"noopener\"") {
|
||||
t.Errorf("expected rel=noopener, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLAtStringStart(t *testing.T) {
|
||||
got := autolinkText("https://example.com is great")
|
||||
if !strings.HasPrefix(got, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
t.Errorf("expected anchor at start, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLAtStringEnd(t *testing.T) {
|
||||
got := autolinkText("checkout https://example.com")
|
||||
want := `checkout <a href="https://example.com" rel="noopener">https://example.com</a>`
|
||||
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, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
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, `<a href="https://short.url/">`) {
|
||||
t.Errorf("expected outer explicit link, got %s", html)
|
||||
}
|
||||
// Inner text must NOT become a nested anchor
|
||||
if strings.Contains(html, `<a href="https://full-url-text.com"`) {
|
||||
t.Errorf("did not expect nested anchor inside link, got %s", html)
|
||||
}
|
||||
// Inner URL should appear as escaped plain text
|
||||
if !strings.Contains(html, "https://full-url-text.com") {
|
||||
t.Errorf("expected inner URL as plain text, got %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(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{"code": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if strings.Contains(html, "<a ") {
|
||||
t.Errorf("did not expect anchor inside code-styled text, got %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<code>") || !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, `<strong><a href="https://example.com" rel="noopener">https://example.com</a></strong>`) {
|
||||
t.Errorf("expected bold-wrapped autolink, got %s", html)
|
||||
}
|
||||
}
|
||||
674
render/blocknote_test.go
Normal file
674
render/blocknote_test.go
Normal file
@ -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, "<details") {
|
||||
t.Errorf("expected details element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<summary") {
|
||||
t.Errorf("expected summary element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Nested content") {
|
||||
t.Errorf("expected nested content: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleListItemOpen(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "toggleListItem",
|
||||
"props": map[string]any{"open": true},
|
||||
"content": "Open toggle",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, ` open`) {
|
||||
t.Errorf("expected open attribute: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "video",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/video.mp4",
|
||||
"caption": "My video",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `<video`) {
|
||||
t.Errorf("expected video element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `controls`) {
|
||||
t.Errorf("expected controls attribute: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "My video") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "audio",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/audio.mp3",
|
||||
"caption": "Podcast episode",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `<audio`) {
|
||||
t.Errorf("expected audio element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `controls`) {
|
||||
t.Errorf("expected controls attribute: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Podcast episode") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "file",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/doc.pdf",
|
||||
"name": "Document.pdf",
|
||||
"caption": "Download here",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `href="https://example.com/doc.pdf"`) {
|
||||
t.Errorf("expected file link: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Document.pdf") {
|
||||
t.Errorf("expected file name: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Download here") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlockNoName(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "file",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/doc.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "https://example.com/doc.pdf") {
|
||||
t.Errorf("expected URL as fallback name: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "statement",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "This is a key statement.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `class="bn-statement"`) {
|
||||
t.Errorf("expected bn-statement class: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "This is a key statement.") {
|
||||
t.Errorf("expected statement text: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBlockWithFormatting(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "statement",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Bold text",
|
||||
"styles": map[string]any{"bold": true},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": " and normal.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, "<strong>Bold text</strong>") {
|
||||
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, "<strong>") {
|
||||
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, "<p") {
|
||||
t.Errorf("expected paragraph tag: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `text-[#ff0000]`) {
|
||||
t.Errorf("expected text color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulletListStringContent(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "bulletListItem",
|
||||
"content": "Test bullet content",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Test bullet content") {
|
||||
t.Errorf("expected bullet content: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableSingleRowDoesNotEmitTbody(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{
|
||||
[]any{map[string]any{"type": "text", "text": "Header"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "<thead>") {
|
||||
t.Errorf("expected thead: %s", html)
|
||||
}
|
||||
if strings.Contains(html, "<tbody>") {
|
||||
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, "<tbody>") {
|
||||
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, "<ul") < 2 {
|
||||
t.Errorf("expected nested ul elements: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardBreakInlineContent(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "Line1"},
|
||||
map[string]any{"type": "hardBreak"},
|
||||
map[string]any{"type": "text", "text": "Line2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Line1") || !strings.Contains(html, "Line2") || !strings.Contains(html, "<br />") {
|
||||
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, `<a href="https://example.com/blank">Steve Blank`) {
|
||||
t.Errorf("expected linked reference: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<li>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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user