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("
  • ") - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) if childrenHTML != "" { sb.WriteString(childrenHTML) } @@ -194,7 +194,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { classNames += " " + alignClass } fmt.Fprintf(&sb, "

    ", 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, "", level, classNames) - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) if childrenHTML != "" { sb.WriteString(childrenHTML) } @@ -230,7 +230,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { classNames += " " + alignClass } fmt.Fprintf(&sb, "

    ", 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, `
    `, checkedAttr) - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) sb.WriteString("") if childrenHTML != "" { fmt.Fprintf(&sb, `
    %s
    `, childrenHTML) @@ -260,7 +260,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { } fmt.Fprintf(&sb, `
    `, openAttr) sb.WriteString(``) - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) sb.WriteString("") if childrenHTML != "" { fmt.Fprintf(&sb, `
    %s
    `, childrenHTML) @@ -273,7 +273,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { lang = l } fmt.Fprintf(&sb, `
    `, html.EscapeString(lang))
    -		sb.WriteString(renderInlineContent(content))
    +		sb.WriteString(renderInlineContent(content, false))
     		sb.WriteString("
    \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, "", cellTag) } } @@ -422,7 +422,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { sb.WriteString("
    \n") if len(content) > 0 { sb.WriteString("

    ") - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) sb.WriteString("

    \n") } if childrenHTML != "" { @@ -462,7 +462,7 @@ func renderBlock(ctx context.Context, block map[string]any) string { default: if len(content) > 0 || childrenHTML != "" { sb.WriteString("
    ") - sb.WriteString(renderInlineContent(content)) + sb.WriteString(renderInlineContent(content, false)) if childrenHTML != "" { sb.WriteString(childrenHTML) } @@ -481,7 +481,7 @@ func renderChildren(ctx context.Context, children any) string { return renderBlocks(ctx, blocks) } -func renderInlineContent(content []map[string]any) string { +func renderInlineContent(content []map[string]any, insideLink bool) string { var sb strings.Builder for _, itemMap := range content { itemType, _ := itemMap["type"].(string) @@ -490,7 +490,18 @@ func renderInlineContent(content []map[string]any) string { switch itemType { 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 bold, ok := styles["bold"].(bool); ok && bold { rendered = "" + rendered + "" @@ -507,7 +518,7 @@ func renderInlineContent(content []map[string]any) string { if strike, ok := styles["strikethrough"].(bool); ok && strike { rendered = "" + 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)