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:
Sergio Benitez
2026-09-25 03:08:15 +00:00
committed by GitHub
co-authored by silverwind bircni
parent 03058691c3
commit 722e52334a
11 changed files with 129 additions and 30 deletions
+3
View File
@@ -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 {
+23
View File
@@ -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{
+38
View File
@@ -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()
+1
View File
@@ -51,6 +51,7 @@ type StandalonePageOptions struct {
type RenderOptions struct {
UseAbsoluteLink bool
FeedExcerpt bool
// relative path from tree root of the branch
RelativePath string