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);