fix(im): fix markdown URL rendering issues in post content (#206)

Preserve fenced code blocks and balanced-parenthesis URLs when converting markdown to post elements. Add regression tests covering code-block URLs and wiki-style links.

Change-Id: I709a3daf3635402848c96b5122edfc67979ed1a4
This commit is contained in:
sammi-bytedance
2026-04-22 20:46:28 +08:00
committed by GitHub
parent 776ee686ff
commit e6f3fa2575
4 changed files with 2912 additions and 22 deletions

2511
coverage.txt Normal file

File diff suppressed because it is too large Load Diff

56
download.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -897,16 +897,12 @@ func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
"content": content,
},
}
data, _ := json.Marshal(payload)
return string(data)
return marshalJSONNoEscape(payload)
}
func buildSingleMDPost(markdown string) string {
return marshalMarkdownPostContent([][]map[string]interface{}{
{{
"tag": "md",
"text": optimizeMarkdownStyle(markdown),
}},
buildPostElementNodes(optimizeMarkdownStyle(markdown)),
})
}
@@ -930,10 +926,7 @@ func buildSegmentedPost(markdown string) string {
if optimized == "" {
continue
}
content = append(content, []map[string]interface{}{{
"tag": "md",
"text": optimized,
}})
content = append(content, buildPostElementNodes(optimized))
}
if len(content) == 0 {
return buildSingleMDPost(markdown)
@@ -948,8 +941,186 @@ func buildMarkdownPostContent(markdown string) string {
return buildSingleMDPost(markdown)
}
// buildPostElementNodes splits optimized markdown text into Feishu post inline
// elements. It tokenizes markdown links/images and bare http(s) URLs:
// - markdown links are kept verbatim inside a {"tag":"md"} segment
// - bare URLs become {"tag":"a"} elements rendered natively by Feishu,
// avoiding the md renderer misinterpreting underscores as italic markers
//
// Fenced code blocks are protected before tokenization so their content remains
// a single md segment, and bare URLs support balanced parentheses in the path.
func buildPostElementNodes(text string) []map[string]interface{} {
protected, codeBlocks := protectMarkdownCodeBlocks(text)
if protected == "" {
return []map[string]interface{}{{
"tag": "md",
"text": text,
}}
}
elems := make([]map[string]interface{}, 0, 4)
prev := 0
for i := 0; i < len(protected); {
end, kind, ok := scanPostToken(protected, i)
if !ok {
i++
continue
}
if i > prev {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:i], codeBlocks))
}
token := protected[i:end]
if kind == postTokenMarkdown {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token, codeBlocks))
} else {
url := trimBareURLToken(token)
if url == "" {
url = token
}
elems = append(elems, map[string]interface{}{
"tag": "a",
"text": url,
"href": url,
})
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token[len(url):], codeBlocks))
}
prev = end
i = end
}
if prev < len(protected) {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:], codeBlocks))
}
if len(elems) == 0 {
return []map[string]interface{}{{
"tag": "md",
"text": text,
}}
}
return elems
}
func trimBareURLToken(token string) string {
trimmed := strings.TrimRight(token, ".,;:!?")
for strings.HasSuffix(trimmed, ")") && strings.Count(trimmed, "(") < strings.Count(trimmed, ")") {
trimmed = strings.TrimSuffix(trimmed, ")")
}
return trimmed
}
type postTokenKind int
const (
postTokenMarkdown postTokenKind = iota
postTokenURL
)
func appendMDPostNode(elems []map[string]interface{}, text string) []map[string]interface{} {
if text == "" {
return elems
}
return append(elems, map[string]interface{}{
"tag": "md",
"text": text,
})
}
func scanPostToken(text string, start int) (end int, kind postTokenKind, ok bool) {
if end, ok = scanMarkdownLinkToken(text, start); ok {
return end, postTokenMarkdown, true
}
if end, ok = scanBareURLToken(text, start); ok {
return end, postTokenURL, true
}
return 0, 0, false
}
func scanMarkdownLinkToken(text string, start int) (int, bool) {
openBracket := start
if text[start] == '!' {
if start+1 >= len(text) || text[start+1] != '[' {
return 0, false
}
openBracket = start + 1
} else if text[start] != '[' {
return 0, false
}
closeBracket := strings.IndexByte(text[openBracket+1:], ']')
if closeBracket < 0 {
return 0, false
}
closeBracket += openBracket + 1
if closeBracket+1 >= len(text) || text[closeBracket+1] != '(' {
return 0, false
}
return scanBalancedParenToken(text, closeBracket+1)
}
func scanBareURLToken(text string, start int) (int, bool) {
if !strings.HasPrefix(text[start:], "http://") && !strings.HasPrefix(text[start:], "https://") {
return 0, false
}
depth := 0
for i := start; i < len(text); i++ {
switch text[i] {
case ' ', '\t', '\n', '\r', '<', '>', '"', '[', ']':
return i, i > start
case '(':
depth++
case ')':
if depth == 0 {
return i, i > start
}
depth--
}
}
return len(text), true
}
func scanBalancedParenToken(text string, openParen int) (int, bool) {
if openParen >= len(text) || text[openParen] != '(' {
return 0, false
}
depth := 0
for i := openParen; i < len(text); i++ {
switch text[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i + 1, true
}
}
}
return 0, false
}
func buildPostElements(text string) string {
return marshalJSONNoEscape(buildPostElementNodes(text))
}
func marshalJSONNoEscape(v interface{}) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
return strings.TrimSuffix(buf.String(), "\n")
}
// marshalStringNoEscape serializes a string to JSON without HTML-escaping
// special characters like &, <, >. Go's json.Marshal escapes them to \u0026
// etc. by default, which breaks URLs containing & in Feishu's md renderer.
func marshalStringNoEscape(s string) string {
return marshalJSONNoEscape(s)
}
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
// Bare URLs are emitted as {"tag":"a"} elements to avoid Feishu's md renderer
// misinterpreting underscores in URLs as italic markers.
func wrapMarkdownAsPost(markdown string) string {
return buildMarkdownPostContent(markdown)
}

View File

@@ -373,19 +373,171 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
}
}
func TestMarshalStringNoEscape(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "ampersand not escaped", input: "a=1&b=2", want: `"a=1&b=2"`},
{name: "angle brackets not escaped", input: "<tag>", want: `"<tag>"`},
{name: "regular string", input: "hello world", want: `"hello world"`},
{name: "url with ampersand", input: "https://example.com?a=1&b=2", want: `"https://example.com?a=1&b=2"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := marshalStringNoEscape(tt.input)
if got != tt.want {
t.Errorf("marshalStringNoEscape(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestBuildPostElements(t *testing.T) {
tests := []struct {
name string
input string
wantSubs []string // substrings that must appear
wantNsubs []string // substrings that must NOT appear
}{
{
name: "plain text no URL",
input: "hello **world**",
wantSubs: []string{`"tag":"md"`, `hello **world**`},
},
{
name: "bare URL only",
input: "https://example.com/path",
wantSubs: []string{`"tag":"a"`, `"text":"https://example.com/path"`, `"href":"https://example.com/path"`},
},
{
name: "bare URL with underscores",
input: "https://example.com/flow_id=abc_def",
wantSubs: []string{`"tag":"a"`, `flow_id=abc_def`},
},
{
name: "bare URL with ampersand not escaped",
input: "https://example.com?a=1&b=2",
wantSubs: []string{`"tag":"a"`, `a=1&b=2`},
},
{
name: "text before and after URL",
input: "click here: https://example.com/path ok?",
wantSubs: []string{`"tag":"md"`, `click here: `, `"tag":"a"`, `https://example.com/path`, ` ok?`},
},
{
name: "markdown link kept in md segment",
input: "[click here](https://example.com/path_with_underscore)",
wantSubs: []string{`"tag":"md"`, `[click here](https://example.com/path_with_underscore)`},
},
{
name: "markdown link not promoted to a tag",
input: "[text](https://example.com)",
wantSubs: []string{`"tag":"md"`},
wantNsubs: []string{`"tag":"a"`},
},
{
name: "multiple bare URLs",
input: "https://a.com/x_y and https://b.com/p_q",
wantSubs: []string{
`"tag":"a"`, `https://a.com/x_y`,
`https://b.com/p_q`,
`"tag":"md"`, ` and `,
},
},
{
name: "mixed markdown and bare URL",
input: "**bold** https://example.com/foo_bar [link](https://example.com) end",
wantSubs: []string{`"tag":"md"`, `**bold**`, `"tag":"a"`, `foo_bar`, `[link](https://example.com)`},
},
{
name: "empty string",
input: "",
wantSubs: []string{`"tag":"md"`, `"text":""`},
},
{
name: "URL followed by comma",
input: "visit https://example.com/path, then click",
wantSubs: []string{`"tag":"a"`, `"href":"https://example.com/path"`},
wantNsubs: []string{`https://example.com/path,`},
},
{
name: "URL followed by period",
input: "see https://example.com/foo.",
wantSubs: []string{`"tag":"a"`, `https://example.com/foo`},
wantNsubs: []string{`https://example.com/foo."`},
},
{
name: "URL with no trailing punctuation unchanged",
input: "https://example.com/foo_bar",
wantSubs: []string{`"href":"https://example.com/foo_bar"`},
},
{
name: "URL with balanced parentheses preserved",
input: "https://en.wikipedia.org/wiki/Foo_(bar)",
wantSubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_(bar)"`},
wantNsubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_"`},
},
{
name: "code block URL stays markdown",
input: "```bash\ncurl https://example.com/foo_bar\n```",
wantSubs: []string{`"tag":"md"`, "```bash\\ncurl https://example.com/foo_bar\\n```"},
wantNsubs: []string{`"tag":"a"`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildPostElements(tt.input)
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("buildPostElements(%q)\n got: %s\n missing: %q", tt.input, got, sub)
}
}
for _, sub := range tt.wantNsubs {
if strings.Contains(got, sub) {
t.Errorf("buildPostElements(%q)\n got: %s\n should not contain: %q", tt.input, got, sub)
}
}
})
}
}
func TestWrapMarkdownAsPost(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**")
content := decodePostContentForTest(t, got)
if len(content) != 1 {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
}
node := decodePostParagraphForTest(t, got, 0)
if node["tag"] != "md" {
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
}
if node["text"] != "hello **world**" {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
}
t.Run("plain markdown", func(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**")
content := decodePostContentForTest(t, got)
if len(content) != 1 {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
}
node := decodePostParagraphForTest(t, got, 0)
if node["tag"] != "md" {
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
}
if node["text"] != "hello **world**" {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
}
})
t.Run("bare URL becomes a tag", func(t *testing.T) {
got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done")
if !strings.Contains(got, `"tag":"a"`) {
t.Fatalf("wrapMarkdownAsPost() bare URL should produce a tag: %s", got)
}
if !strings.Contains(got, `flow_id=abc_def`) {
t.Fatalf("wrapMarkdownAsPost() URL content missing: %s", got)
}
})
t.Run("code block URL stays md", func(t *testing.T) {
got := wrapMarkdownAsPost("```bash\ncurl https://example.com/foo_bar\n```")
if strings.Contains(got, `"tag":"a"`) {
t.Fatalf("wrapMarkdownAsPost() code block URL should stay markdown: %s", got)
}
if !strings.Contains(got, "```bash\\ncurl https://example.com/foo_bar\\n```") {
t.Fatalf("wrapMarkdownAsPost() code block content missing: %s", got)
}
})
}
func TestShouldUseSegmentedPost(t *testing.T) {