diff --git a/render/blocknote.go b/render/blocknote.go index 62e5002..921cad4 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -40,7 +40,7 @@ func renderBlocks(ctx context.Context, blocks []map[string]any) string { content := inlineContentFromRaw(item["content"]) childrenHTML := renderChildren(ctx, item["children"]) sb.WriteString("
", classNames)
- sb.WriteString(renderInlineContent(content))
+ sb.WriteString(renderInlineContent(content, false))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
@@ -218,7 +218,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
classNames += " " + alignClass
}
fmt.Fprintf(&sb, " ")
- sb.WriteString(renderInlineContent(content))
+ sb.WriteString(renderInlineContent(content, false))
sb.WriteString("", classNames)
- sb.WriteString(renderInlineContent(content))
+ sb.WriteString(renderInlineContent(content, false))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
@@ -246,7 +246,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
checkedAttr = " checked"
}
fmt.Fprintf(&sb, `
`)
- sb.WriteString(renderInlineContent(content))
+ sb.WriteString(renderInlineContent(content, false))
sb.WriteString("
")
if childrenHTML != "" {
fmt.Fprintf(&sb, `
\n")
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"
}
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
- sb.WriteString(renderInlineContent(inlineContentFromRaw(cell)))
+ sb.WriteString(renderInlineContent(inlineContentFromRaw(cell), false))
fmt.Fprintf(&sb, "%s>", cellTag)
}
}
@@ -422,7 +422,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
sb.WriteString("`, html.EscapeString(lang))
- sb.WriteString(renderInlineContent(content))
+ sb.WriteString(renderInlineContent(content, false))
sb.WriteString("" + rendered + ""
}
- if code, ok := styles["code"].(bool); ok && code {
+ if isCode {
rendered = "" + rendered + ""
}
@@ -533,11 +544,11 @@ func renderInlineContent(content []map[string]any) string {
href, _ := itemMap["href"].(string)
linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" {
- sb.WriteString(renderInlineContent(linkContent))
+ sb.WriteString(renderInlineContent(linkContent, insideLink))
break
}
fmt.Fprintf(&sb, ``, html.EscapeString(href))
- sb.WriteString(renderInlineContent(linkContent))
+ sb.WriteString(renderInlineContent(linkContent, true))
sb.WriteString("")
case "hardBreak":
@@ -545,13 +556,92 @@ func renderInlineContent(content []map[string]any) string {
default:
if text != "" {
- sb.WriteString(html.EscapeString(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)))
+ sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink))
}
}
}
return sb.String()
}
+
+// autolinkText escapes plain text for HTML output and wraps any https:// URL
+// substring in URL. 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(``)
+ sb.WriteString(escaped)
+ sb.WriteString(``)
+ if tail != "" {
+ sb.WriteString(html.EscapeString(tail))
+ }
+ }
+
+ rest = rest[end:]
+ }
+}
diff --git a/render/blocknote_autolink_test.go b/render/blocknote_autolink_test.go
new file mode 100644
index 0000000..7cf2e77
--- /dev/null
+++ b/render/blocknote_autolink_test.go
@@ -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, " tag in plain prose: %q", got)
+ }
+}
+
+func TestAutolinkText_SingleHTTPSURL(t *testing.T) {
+ got := autolinkText("see https://example.com for more")
+ want := `see https://example.com 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, "https://example.com.`
+ 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, `https://example.com`) {
+ 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 https://example.com)`
+ 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, `https://en.wikipedia.org/wiki/Foo_(bar)`) {
+ 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, "https://example.com`) {
+ t.Errorf("expected anchor at start, got %q", got)
+ }
+}
+
+func TestAutolinkText_URLAtStringEnd(t *testing.T) {
+ got := autolinkText("checkout https://example.com")
+ want := `checkout https://example.com`
+ 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, `https://example.com`) {
+ 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, ``) {
+ t.Errorf("expected outer explicit link, got %s", html)
+ }
+ // Inner text must NOT become a nested anchor
+ if strings.Contains(html, `") || !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, `https://example.com`) {
+ t.Errorf("expected bold-wrapped autolink, got %s", html)
+ }
+}
diff --git a/render/blocknote_test.go b/render/blocknote_test.go
index 97c32bb..6bcc063 100644
--- a/render/blocknote_test.go
+++ b/render/blocknote_test.go
@@ -346,7 +346,7 @@ func TestRenderInlineContentWithTextColor(t *testing.T) {
},
},
}
- html := renderInlineContent(content)
+ html := renderInlineContent(content, false)
if !strings.Contains(html, `class="text-primary"`) {
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]`) {
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`) {
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=") {
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, "") {
t.Errorf("expected bold tag: %s", html)