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:
Alex Dunmow 2026-05-21 02:01:41 +08:00
parent 7eb3e27053
commit 245e38dc95
3 changed files with 341 additions and 22 deletions

View File

@ -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 != "" {
sb.WriteString(html.EscapeString(text)) if insideLink {
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:]
}
}

View 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, "&lt;") || !strings.Contains(got, "&gt;") || !strings.Contains(got, "&amp;") {
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"`, `&#34;`},
{"foo https://example.com'", "&#39;"},
}
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 `&amp;` in both href and text
if !strings.Contains(got, `href="https://example.com/?a=1&amp;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)
}
}

View File

@ -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)