Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render embedded code preview by permlink in markdown #30234

Merged
merged 16 commits into from
Apr 2, 2024
6 changes: 3 additions & 3 deletions modules/indexer/code/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error {
return nil
}

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

// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
Expand Down Expand Up @@ -122,7 +122,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
UpdatedUnix: result.UpdatedUnix,
Language: result.Language,
Color: result.Color,
Lines: HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()),
Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
}, nil
}

Expand Down
1 change: 1 addition & 0 deletions modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node)
var defaultProcessors = []processor{
fullIssuePatternProcessor,
comparePatternProcessor,
codePreviewPatternProcessor,
fullHashPatternProcessor,
shortLinkProcessor,
linkProcessor,
Expand Down
75 changes: 75 additions & 0 deletions modules/markup/html_codepreview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup

import (
"html/template"
"net/url"
"regexp"
"strconv"
"strings"

"code.gitea.io/gitea/modules/httplib"

"golang.org/x/net/html"
)

// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
var codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)

type RenderCodePreviewOptions struct {
FullURL string
OwnerName string
RepoName string
CommitID string
FilePath string

LineStart, LineStop int
}

func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosStop int, htm template.HTML, err error) {
m := codePreviewPattern.FindStringSubmatchIndex(node.Data)
if m == nil {
return 0, 0, "", nil
}

opts := RenderCodePreviewOptions{
FullURL: node.Data[m[0]:m[1]],
OwnerName: node.Data[m[2]:m[3]],
RepoName: node.Data[m[4]:m[5]],
CommitID: node.Data[m[6]:m[7]],
FilePath: node.Data[m[8]:m[9]],
}
if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) {
return 0, 0, "", nil
}
u, err := url.Parse(opts.FilePath)
if err != nil {
return 0, 0, "", err
}
opts.FilePath = strings.TrimPrefix(u.Path, "/")

lineStartStr, lineStopStr, _ := strings.Cut(node.Data[m[10]:m[11]], "-")
lineStart, _ := strconv.Atoi(strings.TrimPrefix(lineStartStr, "L"))
lineStop, _ := strconv.Atoi(strings.TrimPrefix(lineStopStr, "L"))
opts.LineStart, opts.LineStop = lineStart, lineStop
h, err := DefaultProcessorHelper.RenderRepoFileCodePreview(ctx.Ctx, opts)
return m[0], m[1], h, err
}

func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
for node != nil {
urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node)
if err != nil || h == "" {
node = node.NextSibling
continue
}
next := node.NextSibling
nodeText := node.Data
node.Data = nodeText[:urlPosStart]
node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next)
node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: nodeText[urlPosEnd:]}, next)
node = next
}
}
34 changes: 34 additions & 0 deletions modules/markup/html_codepreview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup_test

import (
"context"
"html/template"
"strings"
"testing"

"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/markup"

"github.com/stretchr/testify/assert"
)

func TestRenderCodePreview(t *testing.T) {
markup.Init(&markup.ProcessorHelper{
RenderRepoFileCodePreview: func(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
return "<div>code preview</div>", nil
},
})
test := func(input, expected string) {
buffer, err := markup.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext,
Type: "markdown",
}, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "<p><div>code preview</div></p>")
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>`)
}
3 changes: 3 additions & 0 deletions modules/markup/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"errors"
"fmt"
"html/template"
"io"
"net/url"
"path/filepath"
Expand All @@ -33,6 +34,8 @@ type ProcessorHelper struct {
IsUsernameMentionable func(ctx context.Context, username string) bool

ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute

RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
}

var DefaultProcessorHelper ProcessorHelper
Expand Down
8 changes: 7 additions & 1 deletion modules/markup/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ func createDefaultPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy()

// For JS code copy and Mermaid loading state
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).Globally()

// For code preview
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+$`)).Globally()
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).Globally()
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).Globally()
policy.AllowAttrs("data-line-number").OnElements("span")

// For color preview
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
Expand Down
2 changes: 1 addition & 1 deletion routers/web/repo/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func Search(ctx *context.Context) {
// UpdatedUnix: not supported yet
// Language: not supported yet
// Color: not supported yet
Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, strings.Join(r.LineCodes, "\n")),
Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")),
})
}
}
Expand Down
1 change: 1 addition & 0 deletions services/contexttest/context_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont
base.Locale = &translation.MockLocale{}

ctx := context.NewWebContext(base, opt.Render, nil)
ctx.AppendContextValue(context.WebContextKey, ctx)
ctx.PageData = map[string]any{}
ctx.Data["PageStartTime"] = time.Now()
chiCtx := chi.NewRouteContext()
Expand Down
2 changes: 1 addition & 1 deletion services/markup/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ import (

func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{
FixtureFiles: []string{"user.yml"},
FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"},
})
}
2 changes: 2 additions & 0 deletions services/markup/processorhelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
func ProcessorHelper() *markup.ProcessorHelper {
return &markup.ProcessorHelper{
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags

RenderRepoFileCodePreview: renderRepoFileCodePreview,
IsUsernameMentionable: func(ctx context.Context, username string) bool {
mentionedUser, err := user.GetUserByName(ctx, username)
if err != nil {
Expand Down
106 changes: 106 additions & 0 deletions services/markup/processorhelper_codepreview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup

import (
"bufio"
"context"
"fmt"
"html/template"
"strings"

"code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
gitea_context "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/repository/files"
)

func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) {
opts.LineStop = max(opts.LineStop, 1)
lineCount := opts.LineStop - opts.LineStart + 1
if lineCount <= 0 || lineCount > 140 /* GitHub at most show 140 lines */ {
lineCount = 10
opts.LineStop = opts.LineStart + lineCount
}

dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName)
if err != nil {
return "", err
}

webCtx, ok := ctx.Value(gitea_context.WebContextKey).(*gitea_context.Context)
if !ok {
return "", fmt.Errorf("context is not a web context")
}
doer := webCtx.Doer

perms, err := access.GetUserRepoPermission(ctx, dbRepo, doer)
if err != nil {
return "", err
}
if !perms.CanRead(unit.TypeCode) {
return "", fmt.Errorf("no permission")
}

gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo)
if err != nil {
return "", err
}
defer gitRepo.Close()

commit, err := gitRepo.GetCommit(opts.CommitID)
if err != nil {
return "", err
}

language, _ := files.TryGetContentLanguage(gitRepo, opts.CommitID, opts.FilePath)
blob, err := commit.GetBlobByPath(opts.FilePath)
if err != nil {
return "", err
}

if blob.Size() > setting.UI.MaxDisplayFileSize {
return "", fmt.Errorf("file is too large")
}

dataRc, err := blob.DataAsync()
if err != nil {
return "", err
}
defer dataRc.Close()

reader := bufio.NewReader(dataRc)

for i := 1; i < opts.LineStart; i++ {
if _, err = reader.ReadBytes('\n'); err != nil {
return "", err
}
}

lineNums := make([]int, 0, lineCount)
lineCodes := make([]string, 0, lineCount)
for i := opts.LineStart; i <= opts.LineStop; i++ {
if line, err := reader.ReadString('\n'); err != nil {
break
} else {
lineNums = append(lineNums, i)
lineCodes = append(lineCodes, line)
}
}
highlightLines := code.HighlightSearchResultCode(opts.FilePath, language, lineNums, strings.Join(lineCodes, ""))
return webCtx.RenderToHTML("base/markup_codepreview", map[string]any{
"FullURL": opts.FullURL,
"FilePath": opts.FilePath,
"LineStart": opts.LineStart,
"LineStop": opts.LineStop,
"RepoLink": dbRepo.Link(),
"CommitID": opts.CommitID,
"HighlightLines": highlightLines,
})
}
60 changes: 60 additions & 0 deletions services/markup/processorhelper_codepreview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup

import (
"testing"

"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/contexttest"

"github.com/stretchr/testify/assert"
)

func TestProcessorHelperCodePreview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
FullURL: "http://full",
OwnerName: "user2",
RepoName: "repo1",
CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
FilePath: "/README.md",
LineStart: 1,
LineStop: 10,
})
assert.NoError(t, err)
assert.Equal(t, `<div class="code-preview-container">
<div class="code-preview-header">
<a href="http://full" class="muted" rel="nofollow">/README.md</a>
Lines 1 to 10 in
<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
</div>
<table>
<tbody><tr>
<td class="lines-num"><span data-line-number="1"></span></td>
<td class="lines-code chroma"><span class="gh"># repo1</td>
</tr><tr>
<td class="lines-num"><span data-line-number="2"></span></td>
<td class="lines-code chroma"></span><span class="gh"></span></td>
</tr></tbody>
</table>
</div>
`, string(htm))

ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
_, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{
FullURL: "http://full",
OwnerName: "user15",
RepoName: "big_test_private_1",
CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
FilePath: "/README.md",
LineStart: 1,
LineStop: 10,
})
assert.ErrorContains(t, err, "no permission")
}
17 changes: 17 additions & 0 deletions templates/base/markup_codepreview.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div class="code-preview-container">
<div class="code-preview-header">
<a href="{{.FullURL}}" class="muted" rel="nofollow">{{.FilePath}}</a>
Lines {{.LineStart}} to {{.LineStop}} in
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
<a href="{{.RepoLink}}/src/commit/{{.CommitID}}" rel="nofollow">{{.CommitID | ShortSha}}</a>
</div>
<table>
<tbody>
{{- range .HighlightLines -}}
<tr>
<td class="lines-num"><span data-line-number="{{.Num}}"></span></td>
<td class="lines-code chroma">{{.FormattedContent}}</td>
</tr>
{{- end -}}
</tbody>
</table>
</div>
Loading