From 722e52334a9dc6930d64768b9d35e821be2f663d Mon Sep 17 00:00:00 2001 From: Sergio Benitez Date: Thu, 24 Sep 2026 23:08:15 -0400 Subject: [PATCH] fix(feed): use meaningful lines as comment excerpt (#39276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Screenshot 2026-09-08 at 5 06 47 PM And now renders as: Screenshot 2026-09-08 at 5 08 30 PM --------- Co-authored-by: silverwind Co-authored-by: bircni --- modules/markup/html_codepreview.go | 3 +++ modules/markup/markdown/goldmark.go | 23 ++++++++++++++++ modules/markup/markdown/markdown.go | 38 +++++++++++++++++++++++++++ modules/markup/render.go | 1 + modules/templates/util_render.go | 14 ++++++++-- modules/templates/util_render_test.go | 19 ++++++++++++++ routers/web/feed/convert.go | 7 ++--- services/feed/notifier.go | 16 +++-------- services/feed/notifier_test.go | 21 +++++++++++++++ templates/user/dashboard/feeds.tmpl | 7 ++--- web_src/js/markup/content.ts | 10 ------- 11 files changed, 129 insertions(+), 30 deletions(-) diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go index 3d4d6d137e7..c404116d558 100644 --- a/modules/markup/html_codepreview.go +++ b/modules/markup/html_codepreview.go @@ -56,6 +56,9 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt } func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.RenderOptions.FeedExcerpt { + return + } nodeStop := node.NextSibling for node != nodeStop { if node.Type != html.TextNode { diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index b14760f9c47..f30116ba009 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -11,6 +11,7 @@ import ( "gitea.dev/modules/htmlutil" "gitea.dev/modules/markup" "gitea.dev/modules/markup/internal" + "gitea.dev/modules/markup/markdown/math" "github.com/yuin/goldmark/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 } + if ctx.RenderOptions.FeedExcerpt { + filterFeedExcerpt(node) + } _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 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. func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer { r := &HTMLRenderer{ diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index b906c1c0ac7..015e315995a 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -6,9 +6,11 @@ package markdown import ( "bytes" + "context" "errors" "html/template" "io" + "slices" "strings" "gitea.dev/modules/highlight" @@ -266,6 +268,42 @@ func RenderString(ctx *markup.RenderContext, content string) (template.HTML, err 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. func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { rd, wr := io.Pipe() diff --git a/modules/markup/render.go b/modules/markup/render.go index ecf6f63b672..492b680ed29 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -51,6 +51,7 @@ type StandalonePageOptions struct { type RenderOptions struct { UseAbsoluteLink bool + FeedExcerpt bool // relative path from tree root of the branch RelativePath string diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index e9bafc60eb1..42b195f7cb1 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -190,14 +190,24 @@ func reactionToEmoji(reaction string) template.HTML { return template.HTML(fmt.Sprintf(`:%s:`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) } -func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { - output, err := markdown.RenderString(markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()), input) +func (ut *RenderUtils) renderMarkdownToHtml(input string, feedExcerpt bool) template.HTML { + rctx := markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()) + rctx.RenderOptions.FeedExcerpt = feedExcerpt + output, err := markdown.RenderString(rctx, input) if err != nil { log.Error("RenderString: %v", err) } 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 // 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 diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 6d675b5a1b4..41c621fa41e 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -242,6 +242,25 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit space

` 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: "

explanation

\n"}, + {name: "unclosed code", input: "```suggestion"}, + {name: "table", input: "| a | b |\n|---|---|\n| 1 | 2 |"}, + {name: "math", input: "$$\nx\n$$\nafter", expected: "

after

\n"}, + {name: "image", input: "![img](https://x/y.png)\n\ncaption", expected: "

caption

\n"}, + {name: "linked image", input: "[![img](https://x/y.png)](https://example.com)"}, + {name: "html", input: "
\nx\n\nbody\n
", expected: "

body

\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) { diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index 51babbb0d29..244298790b5 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -50,12 +50,13 @@ func toReleaseLink(ctx *context.Context, act *activities_model.Action) string { } // 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) if act.Repo == nil { return "" } rctx := renderhelper.NewRenderContextRepoComment(ctx, act.Repo).WithUseAbsoluteLink(true) + rctx.RenderOptions.FeedExcerpt = feedExcerpt rendered, err := markdown.RenderString(rctx, content) if err != nil { return "" @@ -222,12 +223,12 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: 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: desc = act.GetIssueTitle(ctx) comment := act.GetIssueInfos()[1] 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: desc = act.GetIssueInfos()[1] diff --git a/services/feed/notifier.go b/services/feed/notifier.go index ae1968aefa3..045aea64ee3 100644 --- a/services/feed/notifier.go +++ b/services/feed/notifier.go @@ -16,8 +16,8 @@ import ( "gitea.dev/modules/git" "gitea.dev/modules/json" "gitea.dev/modules/log" + "gitea.dev/modules/markup/markdown" "gitea.dev/modules/repository" - "gitea.dev/modules/util" notify_service "gitea.dev/services/notify" ) @@ -109,15 +109,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode IsPrivate: issue.Repo.IsPrivate, } - truncatedContent, truncatedRight := util.EllipsisDisplayStringX(comment.Content, 200) - 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) + act.Content = fmt.Sprintf("%d|%s", issue.Index, markdown.FeedExcerpt(ctx, comment.Content)) if issue.IsPull { 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{ ActUserID: review.Reviewer.ID, 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, RepoID: review.Issue.RepoID, Repo: review.Issue.Repo, @@ -245,7 +237,7 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model action := &activities_model.Action{ ActUserID: review.Reviewer.ID, 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, Repo: review.Issue.Repo, IsPrivate: review.Issue.Repo.IsPrivate, diff --git a/services/feed/notifier_test.go b/services/feed/notifier_test.go index c3951ebec6f..b0f4c40ccea 100644 --- a/services/feed/notifier_test.go +++ b/services/feed/notifier_test.go @@ -8,6 +8,8 @@ import ( "testing" activities_model "gitea.dev/models/activities" + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -50,3 +52,22 @@ func TestRenameRepoAction(t *testing.T) { unittest.AssertExistsAndLoadBean(t, actionBean) 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"}) +} diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index ab02522c20f..d187b0450a9 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -108,9 +108,10 @@ {{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}} {{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}} {{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}} - {{$comment := index .GetIssueInfos 1}} - {{if $comment}} -
{{ctx.RenderUtils.MarkdownToHtml $comment}}
+ {{with index .GetIssueInfos 1}} + {{with ctx.RenderUtils.FeedExcerptToHtml .}} +
{{.}}
+ {{end}} {{end}} {{else if .GetOpType.InActions "merge_pull_request"}}
{{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}}
diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index 09dee98754d..cf468eb87a4 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -4,21 +4,11 @@ import {initMarkupCodeCopy} from './codecopy.ts'; import {initMarkupTasklist} from './tasklist.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {initExternalRenderIframe} from './render-iframe.ts'; -import {toggleElemClass} from '../utils/dom.ts'; // code that runs for all markup content export function initMarkupContent(): void { registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe); 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); initMarkupTasklist(el); initMarkupCodeMermaid(el);