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(`
`, 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
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: "\n\ncaption", expected: "caption
\n"}, + {name: "linked image", input: "[](https://example.com)"}, + {name: "html", input: "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: "\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}} -