core/render/blocknote_autolink_test.go
Alex Dunmow 245e38dc95 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>
2026-05-21 02:01:41 +08:00

230 lines
7.1 KiB
Go

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