Skip to content

Commit c044f76

Browse files
wxiaoguangsilverwind
authored andcommitted
Render embedded code preview by permlink in markdown (go-gitea#30234)
The permlink in markdown will be rendered as a code preview block, like GitHub Co-authored-by: silverwind <[email protected]>
1 parent deaabf6 commit c044f76

22 files changed

+450
-21
lines changed

modules/charset/escape_test.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package charset
55

66
import (
7+
"regexp"
78
"strings"
89
"testing"
910

@@ -156,13 +157,16 @@ func TestEscapeControlReader(t *testing.T) {
156157
tests = append(tests, test)
157158
}
158159

160+
re := regexp.MustCompile(`repo.ambiguous_character:\d+,\d+`) // simplify the output for the tests, remove the translation variants
159161
for _, tt := range tests {
160162
t.Run(tt.name, func(t *testing.T) {
161163
output := &strings.Builder{}
162164
status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{})
163165
assert.NoError(t, err)
164166
assert.Equal(t, tt.status, *status)
165-
assert.Equal(t, tt.result, output.String())
167+
outStr := output.String()
168+
outStr = re.ReplaceAllString(outStr, "repo.ambiguous_character")
169+
assert.Equal(t, tt.result, outStr)
166170
})
167171
}
168172
}

modules/csv/csv_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -561,14 +561,14 @@ func TestFormatError(t *testing.T) {
561561
err: &csv.ParseError{
562562
Err: csv.ErrFieldCount,
563563
},
564-
expectedMessage: "repo.error.csv.invalid_field_count",
564+
expectedMessage: "repo.error.csv.invalid_field_count:0",
565565
expectsError: false,
566566
},
567567
{
568568
err: &csv.ParseError{
569569
Err: csv.ErrBareQuote,
570570
},
571-
expectedMessage: "repo.error.csv.unexpected",
571+
expectedMessage: "repo.error.csv.unexpected:0,0",
572572
expectsError: false,
573573
},
574574
{

modules/indexer/code/search.go

+9-7
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type Result struct {
2222
UpdatedUnix timeutil.TimeStamp
2323
Language string
2424
Color string
25-
Lines []ResultLine
25+
Lines []*ResultLine
2626
}
2727

2828
type ResultLine struct {
@@ -70,16 +70,18 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error {
7070
return nil
7171
}
7272

73-
func HighlightSearchResultCode(filename string, lineNums []int, code string) []ResultLine {
73+
func HighlightSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine {
7474
// we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
75-
hl, _ := highlight.Code(filename, "", code)
75+
hl, _ := highlight.Code(filename, language, code)
7676
highlightedLines := strings.Split(string(hl), "\n")
7777

7878
// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
79-
lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
79+
lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums)))
8080
for i := 0; i < len(lines); i++ {
81-
lines[i].Num = lineNums[i]
82-
lines[i].FormattedContent = template.HTML(highlightedLines[i])
81+
lines[i] = &ResultLine{
82+
Num: lineNums[i],
83+
FormattedContent: template.HTML(highlightedLines[i]),
84+
}
8385
}
8486
return lines
8587
}
@@ -122,7 +124,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
122124
UpdatedUnix: result.UpdatedUnix,
123125
Language: result.Language,
124126
Color: result.Color,
125-
Lines: HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()),
127+
Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
126128
}, nil
127129
}
128130

modules/markup/html.go

+1
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node)
171171
var defaultProcessors = []processor{
172172
fullIssuePatternProcessor,
173173
comparePatternProcessor,
174+
codePreviewPatternProcessor,
174175
fullHashPatternProcessor,
175176
shortLinkProcessor,
176177
linkProcessor,

modules/markup/html_codepreview.go

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package markup
5+
6+
import (
7+
"html/template"
8+
"net/url"
9+
"regexp"
10+
"strconv"
11+
"strings"
12+
13+
"code.gitea.io/gitea/modules/httplib"
14+
"code.gitea.io/gitea/modules/log"
15+
16+
"golang.org/x/net/html"
17+
)
18+
19+
// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
20+
var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
21+
22+
type RenderCodePreviewOptions struct {
23+
FullURL string
24+
OwnerName string
25+
RepoName string
26+
CommitID string
27+
FilePath string
28+
29+
LineStart, LineStop int
30+
}
31+
32+
func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) {
33+
m := codePreviewPattern.FindStringSubmatchIndex(node.Data)
34+
if m == nil {
35+
return 0, 0, "", nil
36+
}
37+
38+
opts := RenderCodePreviewOptions{
39+
FullURL: node.Data[m[0]:m[1]],
40+
OwnerName: node.Data[m[2]:m[3]],
41+
RepoName: node.Data[m[4]:m[5]],
42+
CommitID: node.Data[m[6]:m[7]],
43+
FilePath: node.Data[m[8]:m[9]],
44+
}
45+
if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) {
46+
return 0, 0, "", nil
47+
}
48+
u, err := url.Parse(opts.FilePath)
49+
if err != nil {
50+
return 0, 0, "", err
51+
}
52+
opts.FilePath = strings.TrimPrefix(u.Path, "/")
53+
54+
lineStartStr, lineStopStr, _ := strings.Cut(node.Data[m[10]:m[11]], "-")
55+
lineStart, _ := strconv.Atoi(strings.TrimPrefix(lineStartStr, "L"))
56+
lineStop, _ := strconv.Atoi(strings.TrimPrefix(lineStopStr, "L"))
57+
opts.LineStart, opts.LineStop = lineStart, lineStop
58+
h, err := DefaultProcessorHelper.RenderRepoFileCodePreview(ctx.Ctx, opts)
59+
return m[0], m[1], h, err
60+
}
61+
62+
func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
63+
for node != nil {
64+
if node.Type != html.TextNode {
65+
node = node.NextSibling
66+
continue
67+
}
68+
urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node)
69+
if err != nil || h == "" {
70+
if err != nil {
71+
log.Error("Unable to render code preview: %v", err)
72+
}
73+
node = node.NextSibling
74+
continue
75+
}
76+
next := node.NextSibling
77+
textBefore := node.Data[:urlPosStart]
78+
textAfter := node.Data[urlPosEnd:]
79+
// "textBefore" could be empty if there is only a URL in the text node, then an empty node (p, or li) will be left here.
80+
// However, the empty node can't be simply removed, because:
81+
// 1. the following processors will still try to access it (need to double-check undefined behaviors)
82+
// 2. the new node is inserted as "<p>{TextBefore}<div NewNode/>{TextAfter}</p>" (the parent could also be "li")
83+
// then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>",
84+
// so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node.
85+
node.Data = textBefore
86+
node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next)
87+
if textAfter != "" {
88+
node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next)
89+
}
90+
node = next
91+
}
92+
}
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package markup_test
5+
6+
import (
7+
"context"
8+
"html/template"
9+
"strings"
10+
"testing"
11+
12+
"code.gitea.io/gitea/modules/git"
13+
"code.gitea.io/gitea/modules/markup"
14+
15+
"github.com/stretchr/testify/assert"
16+
)
17+
18+
func TestRenderCodePreview(t *testing.T) {
19+
markup.Init(&markup.ProcessorHelper{
20+
RenderRepoFileCodePreview: func(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
21+
return "<div>code preview</div>", nil
22+
},
23+
})
24+
test := func(input, expected string) {
25+
buffer, err := markup.RenderString(&markup.RenderContext{
26+
Ctx: git.DefaultContext,
27+
Type: "markdown",
28+
}, input)
29+
assert.NoError(t, err)
30+
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
31+
}
32+
test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "<p><div>code preview</div></p>")
33+
test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
34+
}

modules/markup/renderer.go

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"errors"
1010
"fmt"
11+
"html/template"
1112
"io"
1213
"net/url"
1314
"path/filepath"
@@ -33,6 +34,8 @@ type ProcessorHelper struct {
3334
IsUsernameMentionable func(ctx context.Context, username string) bool
3435

3536
ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
37+
38+
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
3639
}
3740

3841
var DefaultProcessorHelper ProcessorHelper

modules/markup/sanitizer.go

+15
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ func createDefaultPolicy() *bluemonday.Policy {
6060
// For JS code copy and Mermaid loading state
6161
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
6262

63+
// For code preview
64+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally()
65+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td")
66+
policy.AllowAttrs("data-line-number").OnElements("span")
67+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td")
68+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("code")
69+
70+
// For code preview (unicode escape)
71+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).OnElements("table")
72+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-escape$`)).OnElements("td")
73+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^toggle-escape-button btn interact-bg$`)).OnElements("a") // don't use button, button might submit a form
74+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span")
75+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span")
76+
policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span")
77+
6378
// For color preview
6479
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
6580

modules/translation/mock.go

+13-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package translation
66
import (
77
"fmt"
88
"html/template"
9+
"strings"
910
)
1011

1112
// MockLocale provides a mocked locale without any translations
@@ -19,18 +20,25 @@ func (l MockLocale) Language() string {
1920
return "en"
2021
}
2122

22-
func (l MockLocale) TrString(s string, _ ...any) string {
23-
return s
23+
func (l MockLocale) TrString(s string, args ...any) string {
24+
return sprintAny(s, args...)
2425
}
2526

26-
func (l MockLocale) Tr(s string, a ...any) template.HTML {
27-
return template.HTML(s)
27+
func (l MockLocale) Tr(s string, args ...any) template.HTML {
28+
return template.HTML(sprintAny(s, args...))
2829
}
2930

3031
func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
31-
return template.HTML(key1)
32+
return template.HTML(sprintAny(key1, args...))
3233
}
3334

3435
func (l MockLocale) PrettyNumber(v any) string {
3536
return fmt.Sprint(v)
3637
}
38+
39+
func sprintAny(s string, args ...any) string {
40+
if len(args) == 0 {
41+
return s
42+
}
43+
return s + ":" + fmt.Sprintf(strings.Repeat(",%v", len(args))[1:], args...)
44+
}

options/locale/locale_en-US.ini

+2
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,8 @@ file_view_rendered = View Rendered
12331233
file_view_raw = View Raw
12341234
file_permalink = Permalink
12351235
file_too_large = The file is too large to be shown.
1236+
code_preview_line_from_to = Lines %[1]d to %[2]d in %[3]s
1237+
code_preview_line_in = Line %[1]d in %[2]s
12361238
invisible_runes_header = `This file contains invisible Unicode characters`
12371239
invisible_runes_description = `This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.`
12381240
ambiguous_runes_header = `This file contains ambiguous Unicode characters`

routers/web/repo/search.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func Search(ctx *context.Context) {
8181
// UpdatedUnix: not supported yet
8282
// Language: not supported yet
8383
// Color: not supported yet
84-
Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, strings.Join(r.LineCodes, "\n")),
84+
Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")),
8585
})
8686
}
8787
}

routers/web/repo/wiki_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) {
145145
})
146146
NewWikiPost(ctx)
147147
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
148-
assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg)
148+
assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg)
149149
assertWikiNotExists(t, ctx.Repo.Repository, "_edit")
150150
}
151151

services/contexttest/context_tests.go

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont
6363
base.Locale = &translation.MockLocale{}
6464

6565
ctx := context.NewWebContext(base, opt.Render, nil)
66+
ctx.AppendContextValue(context.WebContextKey, ctx)
6667
ctx.PageData = map[string]any{}
6768
ctx.Data["PageStartTime"] = time.Now()
6869
chiCtx := chi.NewRouteContext()

services/markup/main_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ import (
1111

1212
func TestMain(m *testing.M) {
1313
unittest.MainTest(m, &unittest.TestOptions{
14-
FixtureFiles: []string{"user.yml"},
14+
FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"},
1515
})
1616
}

services/markup/processorhelper.go

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
func ProcessorHelper() *markup.ProcessorHelper {
1515
return &markup.ProcessorHelper{
1616
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
17+
18+
RenderRepoFileCodePreview: renderRepoFileCodePreview,
1719
IsUsernameMentionable: func(ctx context.Context, username string) bool {
1820
mentionedUser, err := user.GetUserByName(ctx, username)
1921
if err != nil {

0 commit comments

Comments
 (0)