mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 14:13:40 +09:00
Comment excerpts in activity feeds previously used either the first 200 display characters of a comment or, for review comments, its first physical line. That excerpt is rendered as Markdown in the feed, so if the excerpt began with a leading blank lines or structural markdown syntax, the excerpt would render as empty or produce broken output. For example, a review comment beginning with a code fence stored only the opening fence, which rendered as an empty code block. This commit instead renders feed excerpts as prose only, dropping code, math, tables, images and HTML, which also fixes already stored excerpts. New excerpts start at the first prose line, and review comments get the same excerpt as issue comments. This produces meaningful excerpts in more cases while preserving their original Markdown. --- For a comment that contains the following: ```` ``` some code ``` hello ```` This previously rendered as: <img width="816" height="118" alt="Screenshot 2026-09-08 at 5 06 47 PM" src="https://github.com/user-attachments/assets/9d125363-72de-46bf-b47a-961245a79c5f" /> And now renders as: <img width="807" height="89" alt="Screenshot 2026-09-08 at 5 08 30 PM" src="https://github.com/user-attachments/assets/d2dcdc6d-d7a9-437f-8e9c-b845fe99fa5e" /> --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com>
375 lines
17 KiB
Go
375 lines
17 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package templates
|
|
|
|
import (
|
|
"context"
|
|
"html/template"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dev/models/gituser"
|
|
"gitea.dev/models/issues"
|
|
"gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/markup"
|
|
"gitea.dev/modules/reqctx"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/setting/config"
|
|
"gitea.dev/modules/test"
|
|
"gitea.dev/modules/translation"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func testInput() string {
|
|
s := ` space @mention-user<SPACE><SPACE>
|
|
/just/a/path.bin
|
|
https://example.com/file.bin
|
|
[local link](file.bin)
|
|
[remote link](https://example.com)
|
|
[[local link|file.bin]]
|
|
[[remote link|https://example.com]]
|
|

|
|

|
|
[[local image|image.jpg]]
|
|
[[remote link|https://example.com/image.jpg]]
|
|
http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
|
http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|
:+1:
|
|
mail@domain.com
|
|
@mention-user test
|
|
#123
|
|
space<SPACE><SPACE>
|
|
`
|
|
return strings.ReplaceAll(s, "<SPACE>", " ")
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
setting.SetupGiteaTestEnv()
|
|
setting.Markdown.RenderOptionsComment.ShortIssuePattern = true
|
|
markup.Init(&markup.RenderHelperFuncs{
|
|
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
|
return username == "mention-user"
|
|
},
|
|
})
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func newTestRenderUtils(t *testing.T) *RenderUtils {
|
|
ctx := reqctx.NewRequestContextForTest(t)
|
|
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
|
|
return NewRenderUtils(ctx)
|
|
}
|
|
|
|
func TestRenderRepoComment(t *testing.T) {
|
|
defer test.MockVariableValue(&setting.RepoRootPath, t.TempDir())()
|
|
mockRepo := &repo.Repository{
|
|
ID: 1, OwnerName: "user13", Name: "repo11",
|
|
Owner: &user_model.User{ID: 13, Name: "user13"},
|
|
Units: []*repo.RepoUnit{},
|
|
}
|
|
t.Run("RenderCommitBody", func(t *testing.T) {
|
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
|
type args struct {
|
|
msg string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want template.HTML
|
|
}{
|
|
{
|
|
name: "multiple lines",
|
|
args: args{
|
|
msg: "first line\nsecond line",
|
|
},
|
|
want: "second line",
|
|
},
|
|
{
|
|
name: "multiple lines with leading newlines",
|
|
args: args{
|
|
msg: "\n\n\n\nfirst line\nsecond line",
|
|
},
|
|
want: "second line",
|
|
},
|
|
{
|
|
name: "multiple lines with trailing newlines",
|
|
args: args{
|
|
msg: "first line\nsecond line\n\n\n",
|
|
},
|
|
want: "second line",
|
|
},
|
|
}
|
|
ut := newTestRenderUtils(t)
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, mockRepo), "RenderCommitBody(%v, %v)", tt.args.msg, nil)
|
|
})
|
|
}
|
|
|
|
expected := `/just/a/path.bin
|
|
<a href="https://example.com/file.bin">https://example.com/file.bin</a>
|
|
[local link](file.bin)
|
|
[remote link](<a href="https://example.com">https://example.com</a>)
|
|
[[local link|file.bin]]
|
|
[[remote link|<a href="https://example.com">https://example.com</a>]]
|
|

|
|

|
|
[[local image|image.jpg]]
|
|
[[remote link|<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>]]
|
|
<a href="http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
|
<a href="http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code>88fc37a3c0</code></a>
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|
<span class="emoji" data-alias="+1">👍</span>
|
|
<a href="mailto:mail@domain.com">mail@domain.com</a>
|
|
<a href="/mention-user">@mention-user</a> test
|
|
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
|
space`
|
|
assert.Equal(t, expected, string(newTestRenderUtils(t).RenderCommitBody(testInput(), mockRepo)))
|
|
})
|
|
|
|
t.Run("RenderCommitMessage", func(t *testing.T) {
|
|
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
|
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo))
|
|
})
|
|
|
|
t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) {
|
|
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a></span>`
|
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
|
})
|
|
|
|
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
|
// a bare URL in the subject must not hijack the default link
|
|
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">https://example.com/file.bin</a></span>`
|
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
|
})
|
|
|
|
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
|
// a URL embedded in larger subject text still becomes its own link
|
|
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted title-full-link"> here</a></span>`
|
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
|
})
|
|
|
|
t.Run("RenderIssueTitle", func(t *testing.T) {
|
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
|
expected := ` space @mention-user<SPACE><SPACE>
|
|
/just/a/path.bin
|
|
https://example.com/file.bin
|
|
[local link](file.bin)
|
|
[remote link](https://example.com)
|
|
[[local link|file.bin]]
|
|
[[remote link|https://example.com]]
|
|

|
|

|
|
[[local image|image.jpg]]
|
|
[[remote link|https://example.com/image.jpg]]
|
|
http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
|
http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|
<span class="emoji" data-alias="+1">👍</span>
|
|
mail@domain.com
|
|
@mention-user test
|
|
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
|
space<SPACE><SPACE>
|
|
`
|
|
expected = strings.ReplaceAll(expected, "<SPACE>", " ")
|
|
assert.Equal(t, expected, string(newTestRenderUtils(t).RenderIssueTitle(testInput(), mockRepo)))
|
|
})
|
|
}
|
|
|
|
func TestRenderIssueTitleCodeSpan(t *testing.T) {
|
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
|
mockRepo := &repo.Repository{
|
|
ID: 1, OwnerName: "user13", Name: "repo11",
|
|
Owner: &user_model.User{ID: 13, Name: "user13"},
|
|
Units: []*repo.RepoUnit{},
|
|
}
|
|
ut := newTestRenderUtils(t)
|
|
|
|
cases := []struct {
|
|
input string
|
|
expected string
|
|
emojiSafe bool
|
|
}{
|
|
{"foo `:100:`", `foo <code class="inline-code-block">:100:</code>`, true},
|
|
{"`#123`", `<code class="inline-code-block">#123</code>`, false},
|
|
{"`88fc37a3c0a4dda553bdcfc80c178a58247f42fb`", `<code class="inline-code-block">88fc37a3c0a4dda553bdcfc80c178a58247f42fb</code>`, false},
|
|
{"foo `:100:", "foo `:100:", true},
|
|
{"foo ` :100:", `foo ` + "`" + ` <span class="emoji" data-alias="100">💯</span>`, true},
|
|
{":100:", `<span class="emoji" data-alias="100">💯</span>`, true},
|
|
{"#123", `<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>`, false},
|
|
{"`x`:100:", `<code class="inline-code-block">x</code><span class="emoji" data-alias="100">💯</span>`, true},
|
|
{"a `:100:` b `:+1:` c", `a <code class="inline-code-block">:100:</code> b <code class="inline-code-block">:+1:</code> c`, true},
|
|
}
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.expected, string(ut.RenderIssueTitle(c.input, mockRepo)), "input=%q", c.input)
|
|
if c.emojiSafe {
|
|
assert.Equal(t, c.expected, string(ut.RenderIssueSimpleTitle(c.input)), "simple input=%q", c.input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderMarkdownToHtml(t *testing.T) {
|
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
|
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
|
|
/just/a/path.bin
|
|
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
|
|
<a href="/file.bin" rel="nofollow">local link</a>
|
|
<a href="https://example.com" rel="nofollow">remote link</a>
|
|
<a href="/file.bin" rel="nofollow">local link</a>
|
|
<a href="https://example.com" rel="nofollow">remote link</a>
|
|
<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
|
|
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
|
|
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
|
|
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
|
|
<a href="http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
|
<a href="http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
|
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|
<span class="emoji" data-alias="+1">👍</span>
|
|
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
|
<a href="/mention-user" rel="nofollow">@mention-user</a> test
|
|
#123
|
|
space</p>
|
|
`
|
|
assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput())))
|
|
|
|
defer test.MockVariableValue(&setting.Markdown.MathCodeBlockOptions, setting.MarkdownMathCodeBlockOptions{ParseBlockDollar: true})()
|
|
for _, tc := range []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{name: "code", input: "```suggestion\nreplacement\n```\n\nexplanation", expected: "<p>explanation</p>\n"},
|
|
{name: "unclosed code", input: "```suggestion"},
|
|
{name: "table", input: "| a | b |\n|---|---|\n| 1 | 2 |"},
|
|
{name: "math", input: "$$\nx\n$$\nafter", expected: "<p>after</p>\n"},
|
|
{name: "image", input: "\n\ncaption", expected: "<p>caption</p>\n"},
|
|
{name: "linked image", input: "[](https://example.com)"},
|
|
{name: "html", input: "<details>\n<summary>x</summary>\n\nbody\n</details>", expected: "<p>body</p>\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.expected, string(newTestRenderUtils(t).FeedExcerptToHtml(tc.input)))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderPackageMarkdown(t *testing.T) {
|
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
|
mockRepo := &repo.Repository{
|
|
ID: 1, OwnerName: "user13", Name: "repo11", DefaultBranch: "main",
|
|
Owner: &user_model.User{ID: 13, Name: "user13"},
|
|
Units: []*repo.RepoUnit{},
|
|
}
|
|
ut := newTestRenderUtils(t)
|
|
|
|
t.Run("LinkedRepoWithDirectory", func(t *testing.T) {
|
|
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)\n", mockRepo, "pkg-subdir")
|
|
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/pkg-subdir/docs/getting-started.md" rel="nofollow">docs</a>
|
|
<a href="/user13/repo11/src/branch/main/pkg-subdir/logo.png" target="_blank" rel="nofollow noopener"><img src="/user13/repo11/media/branch/main/pkg-subdir/logo.png" alt="logo"/></a></p>
|
|
</div>`
|
|
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
|
})
|
|
|
|
t.Run("LinkedRepoWithEmptyDirectory", func(t *testing.T) {
|
|
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", mockRepo, "")
|
|
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/docs/getting-started.md" rel="nofollow">docs</a></p>
|
|
</div>`
|
|
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
|
})
|
|
|
|
t.Run("UnlinkedRepo", func(t *testing.T) {
|
|
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", nil, "pkg-subdir")
|
|
expected := `<div class="markup markdown"><p><a href="/docs/getting-started.md" rel="nofollow">docs</a></p>
|
|
</div>`
|
|
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
|
})
|
|
}
|
|
|
|
func TestRenderLabels(t *testing.T) {
|
|
ut := newTestRenderUtils(t)
|
|
label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
|
|
issue := &issues.Issue{}
|
|
expected := `/owner/repo/issues?labels=123`
|
|
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
|
|
|
|
label = &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
|
|
issue = &issues.Issue{IsPull: true}
|
|
expected = `/owner/repo/pulls?labels=123`
|
|
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
|
|
|
|
expectedLabel := `<span class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></span>`
|
|
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
|
|
|
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true}
|
|
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">></div></span>`
|
|
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
|
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true, ExclusiveOrder: 1}
|
|
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">></div><div class="ui label scope-right">1</div></span>`
|
|
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
|
}
|
|
|
|
func TestUserMention(t *testing.T) {
|
|
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
|
rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
|
|
assert.Equal(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
|
|
}
|
|
|
|
func TestAvatarStack(t *testing.T) {
|
|
defer test.MockVariableValue(&config.SkipDatabaseConfig, true)()
|
|
|
|
ut := newTestRenderUtils(t)
|
|
mkCo := func(name, email string) *git.CommitIdentity {
|
|
return &git.CommitIdentity{Name: name, Email: email}
|
|
}
|
|
authorSig := mkCo("Alice", "alice@example.com")
|
|
mkData := func(co ...*git.CommitIdentity) *gituser.AvatarStackData {
|
|
all := append([]*git.CommitIdentity{authorSig}, co...)
|
|
return gituser.BuildAvatarStackData(t.Context(), all, &user_model.EmailUserMap{})
|
|
}
|
|
|
|
t.Run("lone author renders bare name, no label", func(t *testing.T) {
|
|
got := string(ut.AvatarStackWithNames(mkData()))
|
|
assert.Contains(t, got, `<span class="avatar-stack-names">`)
|
|
assert.Contains(t, got, "Alice")
|
|
assert.NotContains(t, got, "avatar_stack_and")
|
|
assert.NotContains(t, got, "avatar_stack_people")
|
|
})
|
|
|
|
t.Run("two participants use and label", func(t *testing.T) {
|
|
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"))))
|
|
assert.Contains(t, got, "repo.commits.avatar_stack_and")
|
|
assert.Contains(t, got, "Bob")
|
|
assert.NotContains(t, got, "avatar_stack_people")
|
|
assert.Contains(t, got, `<span class="avatar-stack">`)
|
|
})
|
|
|
|
t.Run("three participants switch to N people label with tippy popup", func(t *testing.T) {
|
|
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"), mkCo("Carol", "carol@example.com"))))
|
|
assert.Contains(t, got, "repo.commits.avatar_stack_people:3")
|
|
assert.NotContains(t, got, "repo.commits.avatar_stack_and")
|
|
assert.Contains(t, got, `data-global-init="initAvatarStackPopup"`)
|
|
assert.Contains(t, got, `<div class="tippy-target">`)
|
|
assert.Contains(t, got, `class="avatar-stack-popup"`)
|
|
})
|
|
|
|
t.Run("overflow chip renders beyond 10 participants", func(t *testing.T) {
|
|
cos := make([]*git.CommitIdentity, 0, renderAvatarStackMaxVisible+1)
|
|
for i := range renderAvatarStackMaxVisible + 1 {
|
|
cos = append(cos, mkCo("X", strconv.Itoa(i)+"@example.com"))
|
|
}
|
|
got := ut.AvatarStack(gituser.BuildAvatarStackData(t.Context(), cos, &user_model.EmailUserMap{}))
|
|
assert.Contains(t, got, `class="avatar-stack-overflow-chip`)
|
|
assert.Contains(t, got, "+1")
|
|
})
|
|
}
|