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>
230 lines
7.1 KiB
Go
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, "<") || !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)
|
|
}
|
|
}
|