feat(render): autolink https:// URLs in BlockNote inline text
Plain-text nodes inside paragraphs, headings, lists and table cells now auto-wrap any https:// URL in <a href="..." rel="noopener">URL</a>. - Bare domains, www. and http:// URLs are intentionally not linked - Suppressed inside explicit `link` inline nodes (no nested anchors) - Suppressed inside `code`-styled spans (URL stays literal) - Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL - Closing parens/brackets/braces are kept inside the URL only when balanced with an opener (so Wikipedia-style _(bar) is preserved but `(see https://x.com)` doesn't eat the trailing paren) - Bold/italic/color style wrappers compose around the anchor `renderInlineContent` gains an `insideLink bool` parameter; all existing call sites pass `false`, and the `link` branch recurses with `true`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7eb3e27053
commit
245e38dc95
@ -40,7 +40,7 @@ func renderBlocks(ctx context.Context, blocks []map[string]any) string {
|
|||||||
content := inlineContentFromRaw(item["content"])
|
content := inlineContentFromRaw(item["content"])
|
||||||
childrenHTML := renderChildren(ctx, item["children"])
|
childrenHTML := renderChildren(ctx, item["children"])
|
||||||
sb.WriteString("<li>")
|
sb.WriteString("<li>")
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -194,7 +194,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
classNames += " " + alignClass
|
classNames += " " + alignClass
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
|
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -218,7 +218,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
classNames += " " + alignClass
|
classNames += " " + alignClass
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
|
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -230,7 +230,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
classNames += " " + alignClass
|
classNames += " " + alignClass
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
|
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -246,7 +246,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
checkedAttr = " 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)
|
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(renderInlineContent(content, false))
|
||||||
sb.WriteString("</span>")
|
sb.WriteString("</span>")
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
|
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
|
||||||
@ -260,7 +260,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
|
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
|
||||||
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
|
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
sb.WriteString("</summary>")
|
sb.WriteString("</summary>")
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
|
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
|
||||||
@ -273,7 +273,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
lang = l
|
lang = l
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
sb.WriteString("</code></pre>\n")
|
sb.WriteString("</code></pre>\n")
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
@ -375,7 +375,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
||||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell)))
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell), false))
|
||||||
fmt.Fprintf(&sb, "</%s>", cellTag)
|
fmt.Fprintf(&sb, "</%s>", cellTag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -422,7 +422,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
sb.WriteString("<div class=\"bn-statement\">\n")
|
sb.WriteString("<div class=\"bn-statement\">\n")
|
||||||
if len(content) > 0 {
|
if len(content) > 0 {
|
||||||
sb.WriteString("<p>")
|
sb.WriteString("<p>")
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
sb.WriteString("</p>\n")
|
sb.WriteString("</p>\n")
|
||||||
}
|
}
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
@ -462,7 +462,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
|||||||
default:
|
default:
|
||||||
if len(content) > 0 || childrenHTML != "" {
|
if len(content) > 0 || childrenHTML != "" {
|
||||||
sb.WriteString("<div>")
|
sb.WriteString("<div>")
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -481,7 +481,7 @@ func renderChildren(ctx context.Context, children any) string {
|
|||||||
return renderBlocks(ctx, blocks)
|
return renderBlocks(ctx, blocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderInlineContent(content []map[string]any) string {
|
func renderInlineContent(content []map[string]any, insideLink bool) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, itemMap := range content {
|
for _, itemMap := range content {
|
||||||
itemType, _ := itemMap["type"].(string)
|
itemType, _ := itemMap["type"].(string)
|
||||||
@ -490,7 +490,18 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
|
|
||||||
switch itemType {
|
switch itemType {
|
||||||
case "text":
|
case "text":
|
||||||
rendered := html.EscapeString(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 styles != nil {
|
||||||
if bold, ok := styles["bold"].(bool); ok && bold {
|
if bold, ok := styles["bold"].(bool); ok && bold {
|
||||||
rendered = "<strong>" + rendered + "</strong>"
|
rendered = "<strong>" + rendered + "</strong>"
|
||||||
@ -507,7 +518,7 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
||||||
rendered = "<s>" + rendered + "</s>"
|
rendered = "<s>" + rendered + "</s>"
|
||||||
}
|
}
|
||||||
if code, ok := styles["code"].(bool); ok && code {
|
if isCode {
|
||||||
rendered = "<code>" + rendered + "</code>"
|
rendered = "<code>" + rendered + "</code>"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,11 +544,11 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
href, _ := itemMap["href"].(string)
|
href, _ := itemMap["href"].(string)
|
||||||
linkContent := inlineContentFromRaw(itemMap["content"])
|
linkContent := inlineContentFromRaw(itemMap["content"])
|
||||||
if href == "" {
|
if href == "" {
|
||||||
sb.WriteString(renderInlineContent(linkContent))
|
sb.WriteString(renderInlineContent(linkContent, insideLink))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
||||||
sb.WriteString(renderInlineContent(linkContent))
|
sb.WriteString(renderInlineContent(linkContent, true))
|
||||||
sb.WriteString("</a>")
|
sb.WriteString("</a>")
|
||||||
|
|
||||||
case "hardBreak":
|
case "hardBreak":
|
||||||
@ -545,13 +556,92 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
if text != "" {
|
if text != "" {
|
||||||
|
if insideLink {
|
||||||
sb.WriteString(html.EscapeString(text))
|
sb.WriteString(html.EscapeString(text))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(autolinkText(text))
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if rawContent, ok := itemMap["content"]; ok {
|
if rawContent, ok := itemMap["content"]; ok {
|
||||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent)))
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -346,7 +346,7 @@ func TestRenderInlineContentWithTextColor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
html := renderInlineContent(content)
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
if !strings.Contains(html, `class="text-primary"`) {
|
if !strings.Contains(html, `class="text-primary"`) {
|
||||||
t.Errorf("expected text color class: %s", html)
|
t.Errorf("expected text color class: %s", html)
|
||||||
@ -363,7 +363,7 @@ func TestRenderInlineContentWithBackgroundColor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
html := renderInlineContent(content)
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
if !strings.Contains(html, `bg-[#ffcc00]`) {
|
if !strings.Contains(html, `bg-[#ffcc00]`) {
|
||||||
t.Errorf("expected background color class: %s", html)
|
t.Errorf("expected background color class: %s", html)
|
||||||
@ -381,7 +381,7 @@ func TestRenderInlineContentWithBothColors(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
html := renderInlineContent(content)
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
if !strings.Contains(html, `text-foreground`) {
|
if !strings.Contains(html, `text-foreground`) {
|
||||||
t.Errorf("expected text color class: %s", html)
|
t.Errorf("expected text color class: %s", html)
|
||||||
@ -401,7 +401,7 @@ func TestRenderInlineContentWithDefaultColor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
html := renderInlineContent(content)
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
if strings.Contains(html, "class=") {
|
if strings.Contains(html, "class=") {
|
||||||
t.Errorf("default color should not add class: %s", html)
|
t.Errorf("default color should not add class: %s", html)
|
||||||
@ -419,7 +419,7 @@ func TestRenderInlineContentWithColorsAndOtherStyles(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
html := renderInlineContent(content)
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
if !strings.Contains(html, "<strong>") {
|
if !strings.Contains(html, "<strong>") {
|
||||||
t.Errorf("expected bold tag: %s", html)
|
t.Errorf("expected bold tag: %s", html)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user