fix(feed): use meaningful lines as comment excerpt (#39276)

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>
This commit is contained in:
authored and GitHub committed 2026-09-25 03:08:15 +00:00
1 parent 03058691c3
commit 722e52334a
11 files changed
+129 -30

No files matched your search

+3
View File
@@ -56,6 +56,9 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt
} }
func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.RenderOptions.FeedExcerpt {
return
}
nodeStop := node.NextSibling nodeStop := node.NextSibling
for node != nodeStop { for node != nodeStop {
if node.Type != html.TextNode { if node.Type != html.TextNode {
+23
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/modules/htmlutil" "gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
"gitea.dev/modules/markup/internal" "gitea.dev/modules/markup/internal"
"gitea.dev/modules/markup/markdown/math"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast" east "github.com/yuin/goldmark/extension/ast"
@@ -54,6 +55,9 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
} }
tocMode = rc.TOC tocMode = rc.TOC
} }
if ctx.RenderOptions.FeedExcerpt {
filterFeedExcerpt(node)
}
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering { if !entering {
@@ -96,6 +100,25 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
} }
} }
func filterFeedExcerpt(parent ast.Node) {
for node := parent.FirstChild(); node != nil; {
next := node.NextSibling()
switch node.Kind() {
case ast.KindText, ast.KindAutoLink, math.KindInline:
case ast.KindParagraph, ast.KindHeading, ast.KindTextBlock, ast.KindBlockquote, ast.KindList, ast.KindListItem,
ast.KindEmphasis, ast.KindCodeSpan, ast.KindLink,
east.KindStrikethrough, east.KindDefinitionList, east.KindDefinitionTerm, east.KindDefinitionDescription:
filterFeedExcerpt(node)
if !node.HasChildren() {
parent.RemoveChild(parent, node)
}
default:
parent.RemoveChild(parent, node)
}
node = next
}
}
// NewHTMLRenderer creates a HTMLRenderer to render in the gitea form. // NewHTMLRenderer creates a HTMLRenderer to render in the gitea form.
func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer { func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer {
r := &HTMLRenderer{ r := &HTMLRenderer{
+38
View File
@@ -6,9 +6,11 @@ package markdown
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"html/template" "html/template"
"io" "io"
"slices"
"strings" "strings"
"gitea.dev/modules/highlight" "gitea.dev/modules/highlight"
@@ -266,6 +268,42 @@ func RenderString(ctx *markup.RenderContext, content string) (template.HTML, err
return template.HTML(buf.String()), nil return template.HTML(buf.String()), nil
} }
// FeedExcerpt returns the source of the prose rendered by a feed excerpt, truncated outside of inline markup.
func FeedExcerpt(ctx context.Context, content string) string {
rctx := markup.NewRenderContext(ctx)
rctx.RenderOptions.FeedExcerpt = true
pc := newParserContext(rctx)
pc.Set(renderConfigKey, &RenderConfig{})
start, texts := len(content), []text.Segment(nil)
_ = ast.Walk(SpecializedMarkdown(rctx).goldmarkMarkdown.Parser().Parse(text.NewReader([]byte(content)), parser.WithContext(pc)), func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.Type() == ast.TypeBlock && node.Lines().Len() > 0 {
start = min(start, strings.LastIndexByte(content[:node.Lines().At(0).Start], '\n')+1)
} else if textNode, ok := node.(*ast.Text); ok && entering && node.Parent().Type() == ast.TypeBlock {
texts = append(texts, textNode.Segment)
}
return ast.WalkContinue, nil
})
excerpt := giteautil.EllipsisDisplayString(content[start:], 200)
kept, ok := strings.CutSuffix(excerpt, "…")
if excerpt == content[start:] || !ok {
return excerpt
}
end := start + len(kept)
for _, segment := range slices.Backward(texts) {
if segment.Start < end {
end = min(end, segment.Stop)
break
}
}
excerpt = content[start:end]
// in case the content is in a Latin family language, we remove the last broken word.
if lastSpaceIdx := strings.LastIndexByte(excerpt, ' '); lastSpaceIdx != -1 && len(excerpt)-lastSpaceIdx+len("…") < 15 {
excerpt = excerpt[:lastSpaceIdx]
}
return excerpt + "…"
}
// RenderRaw renders Markdown to HTML without handling special links. // RenderRaw renders Markdown to HTML without handling special links.
func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
rd, wr := io.Pipe() rd, wr := io.Pipe()
+1
View File
@@ -51,6 +51,7 @@ type StandalonePageOptions struct {
type RenderOptions struct { type RenderOptions struct {
UseAbsoluteLink bool UseAbsoluteLink bool
FeedExcerpt bool
// relative path from tree root of the branch // relative path from tree root of the branch
RelativePath string RelativePath string
+12 -2
View File
@@ -190,14 +190,24 @@ func reactionToEmoji(reaction string) template.HTML {
return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
} }
func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { func (ut *RenderUtils) renderMarkdownToHtml(input string, feedExcerpt bool) template.HTML {
output, err := markdown.RenderString(markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()), input) rctx := markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas())
rctx.RenderOptions.FeedExcerpt = feedExcerpt
output, err := markdown.RenderString(rctx, input)
if err != nil { if err != nil {
log.Error("RenderString: %v", err) log.Error("RenderString: %v", err)
} }
return output return output
} }
func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML {
return ut.renderMarkdownToHtml(input, false)
}
func (ut *RenderUtils) FeedExcerptToHtml(input string) template.HTML {
return ut.renderMarkdownToHtml(input, true)
}
// RenderPackageMarkdown renders package page Markdown so relative links resolve against the // RenderPackageMarkdown renders package page Markdown so relative links resolve against the
// linked repository's default branch instead of the site root, falling back to plain rendering // linked repository's default branch instead of the site root, falling back to plain rendering
// when there is no linked repository. pkgTreePath optionally roots links in a subdirectory // when there is no linked repository. pkgTreePath optionally roots links in a subdirectory
+19
View File
@@ -242,6 +242,25 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
space</p> space</p>
` `
assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput()))) 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: "![img](https://x/y.png)\n\ncaption", expected: "<p>caption</p>\n"},
{name: "linked image", input: "[![img](https://x/y.png)](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) { func TestRenderPackageMarkdown(t *testing.T) {
+4 -3
View File
@@ -50,12 +50,13 @@ func toReleaseLink(ctx *context.Context, act *activities_model.Action) string {
} }
// renderCommentMarkdown renders the comment markdown to html // renderCommentMarkdown renders the comment markdown to html
func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML { func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, content string, feedExcerpt bool) template.HTML {
_ = act.LoadRepo(ctx) _ = act.LoadRepo(ctx)
if act.Repo == nil { if act.Repo == nil {
return "" return ""
} }
rctx := renderhelper.NewRenderContextRepoComment(ctx, act.Repo).WithUseAbsoluteLink(true) rctx := renderhelper.NewRenderContextRepoComment(ctx, act.Repo).WithUseAbsoluteLink(true)
rctx.RenderOptions.FeedExcerpt = feedExcerpt
rendered, err := markdown.RenderString(rctx, content) rendered, err := markdown.RenderString(rctx, content)
if err != nil { if err != nil {
return "" return ""
@@ -222,12 +223,12 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
desc = strings.Join(act.GetIssueInfos(), "#") desc = strings.Join(act.GetIssueInfos(), "#")
content = renderCommentMarkdown(ctx, act, act.GetIssueContent(ctx)) content = renderCommentMarkdown(ctx, act, act.GetIssueContent(ctx), false)
case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull: case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull:
desc = act.GetIssueTitle(ctx) desc = act.GetIssueTitle(ctx)
comment := act.GetIssueInfos()[1] comment := act.GetIssueInfos()[1]
if len(comment) != 0 { if len(comment) != 0 {
desc += "\n\n" + string(renderCommentMarkdown(ctx, act, comment)) desc += "\n\n" + string(renderCommentMarkdown(ctx, act, comment, true))
} }
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
desc = act.GetIssueInfos()[1] desc = act.GetIssueInfos()[1]
+4 -12
View File
@@ -16,8 +16,8 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/repository" "gitea.dev/modules/repository"
"gitea.dev/modules/util"
notify_service "gitea.dev/services/notify" notify_service "gitea.dev/services/notify"
) )
@@ -109,15 +109,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode
IsPrivate: issue.Repo.IsPrivate, IsPrivate: issue.Repo.IsPrivate,
} }
truncatedContent, truncatedRight := util.EllipsisDisplayStringX(comment.Content, 200) act.Content = fmt.Sprintf("%d|%s", issue.Index, markdown.FeedExcerpt(ctx, comment.Content))
if truncatedRight != "" {
// in case the content is in a Latin family language, we remove the last broken word.
lastSpaceIdx := strings.LastIndex(truncatedContent, " ")
if lastSpaceIdx != -1 && (len(truncatedContent)-lastSpaceIdx < 15) {
truncatedContent = truncatedContent[:lastSpaceIdx] + "…"
}
}
act.Content = fmt.Sprintf("%d|%s", issue.Index, truncatedContent)
if issue.IsPull { if issue.IsPull {
act.OpType = activities_model.ActionCommentPull act.OpType = activities_model.ActionCommentPull
@@ -229,7 +221,7 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model
actions = append(actions, &activities_model.Action{ actions = append(actions, &activities_model.Action{
ActUserID: review.Reviewer.ID, ActUserID: review.Reviewer.ID,
ActUser: review.Reviewer, ActUser: review.Reviewer,
Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comm.Content, "\n")[0]), Content: fmt.Sprintf("%d|%s", review.Issue.Index, markdown.FeedExcerpt(ctx, comm.Content)),
OpType: activities_model.ActionCommentPull, OpType: activities_model.ActionCommentPull,
RepoID: review.Issue.RepoID, RepoID: review.Issue.RepoID,
Repo: review.Issue.Repo, Repo: review.Issue.Repo,
@@ -245,7 +237,7 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model
action := &activities_model.Action{ action := &activities_model.Action{
ActUserID: review.Reviewer.ID, ActUserID: review.Reviewer.ID,
ActUser: review.Reviewer, ActUser: review.Reviewer,
Content: fmt.Sprintf("%d|%s", review.Issue.Index, strings.Split(comment.Content, "\n")[0]), Content: fmt.Sprintf("%d|%s", review.Issue.Index, markdown.FeedExcerpt(ctx, comment.Content)),
RepoID: review.Issue.RepoID, RepoID: review.Issue.RepoID,
Repo: review.Issue.Repo, Repo: review.Issue.Repo,
IsPrivate: review.Issue.Repo.IsPrivate, IsPrivate: review.Issue.Repo.IsPrivate,
+21
View File
@@ -8,6 +8,8 @@ import (
"testing" "testing"
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
@@ -50,3 +52,22 @@ func TestRenameRepoAction(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, actionBean) unittest.AssertExistsAndLoadBean(t, actionBean)
unittest.CheckConsistencyFor(t, &activities_model.Action{}) unittest.CheckConsistencyFor(t, &activities_model.Action{})
} }
func TestPullRequestReviewActionExcerpts(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issue.Repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
codeComment := &issues_model.Comment{Type: issues_model.CommentTypeCode, PosterID: doer.ID, IssueID: issue.ID, Content: "```suggestion\n" + strings.Repeat("x", 300) + "\n```\nexplanation [link](https://example.com/" + strings.Repeat("x", 300) + ")"}
reviewComment := &issues_model.Comment{Type: issues_model.CommentTypeReview, PosterID: doer.ID, IssueID: issue.ID, Content: "![image](image.png)\n\n**summary**\nmore"}
assert.NoError(t, db.Insert(t.Context(), codeComment, reviewComment))
NewNotifier().PullRequestReview(t.Context(), nil, &issues_model.Review{
Type: issues_model.ReviewTypeApprove, Reviewer: doer, ReviewerID: doer.ID, Issue: issue, IssueID: issue.ID,
CodeComments: issues_model.CodeComments{"file.txt": {1: {codeComment}}},
}, reviewComment, nil)
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{OpType: activities_model.ActionCommentPull, CommentID: codeComment.ID, Content: "1|explanation…"})
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{OpType: activities_model.ActionApprovePullRequest, CommentID: reviewComment.ID, Content: "1|**summary**\nmore"})
}
+4 -3
View File
@@ -108,9 +108,10 @@
<span class="tw-inline-block tw-truncate issue title">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</span> <span class="tw-inline-block tw-truncate issue title">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</span>
{{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}} {{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}}
<a href="{{.GetCommentLink ctx}}" class="tw-inline-block tw-truncate tw-max-w-full tw-self-start issue title">{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}</a> <a href="{{.GetCommentLink ctx}}" class="tw-inline-block tw-truncate tw-max-w-full tw-self-start issue title">{{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}}</a>
{{$comment := index .GetIssueInfos 1}} {{with index .GetIssueInfos 1}}
{{if $comment}} {{with ctx.RenderUtils.FeedExcerptToHtml .}}
<div class="render-content markup truncated-markup">{{ctx.RenderUtils.MarkdownToHtml $comment}}</div> <div class="render-content markup">{{.}}</div>
{{end}}
{{end}} {{end}}
{{else if .GetOpType.InActions "merge_pull_request"}} {{else if .GetOpType.InActions "merge_pull_request"}}
<div class="item-body tw-text-text">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</div> <div class="item-body tw-text-text">{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}</div>
-10
View File
@@ -4,21 +4,11 @@ import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupTasklist} from './tasklist.ts'; import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initExternalRenderIframe} from './render-iframe.ts'; import {initExternalRenderIframe} from './render-iframe.ts';
import {toggleElemClass} from '../utils/dom.ts';
// code that runs for all markup content // code that runs for all markup content
export function initMarkupContent(): void { export function initMarkupContent(): void {
registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe); registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe);
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => { registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
if (el.matches('.truncated-markup')) {
// when the rendered markup is truncated (e.g.: user's home activity feed)
// we should not initialize any of the features (e.g.: code copy button), due to:
// * truncated markup already means that the container doesn't want to show complex contents
// * truncated markup may contain incomplete HTML/mermaid elements
// so the only thing we need to do is to remove the "is-loading" class added by the backend render.
toggleElemClass(el.querySelectorAll('.is-loading'), 'is-loading', false);
return;
}
initMarkupCodeCopy(el); initMarkupCodeCopy(el);
initMarkupTasklist(el); initMarkupTasklist(el);
initMarkupCodeMermaid(el); initMarkupCodeMermaid(el);