mirror of https://github.com/go-gitea/gitea.git
Render embedded code preview by permlink in markdown (#30234)
The permlink in markdown will be rendered as a code preview block, like GitHub Co-authored-by: silverwind <me@silverwind.io>pull/30175/head^2
parent
eb505b128c
commit
ca5c895efb
@ -0,0 +1,92 @@
|
||||
// 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"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"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 {
|
||||
if node.Type != html.TextNode {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
urlPosStart, urlPosEnd, h, err := renderCodeBlock(ctx, node)
|
||||
if err != nil || h == "" {
|
||||
if err != nil {
|
||||
log.Error("Unable to render code preview: %v", err)
|
||||
}
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
next := node.NextSibling
|
||||
textBefore := node.Data[:urlPosStart]
|
||||
textAfter := node.Data[urlPosEnd:]
|
||||
// "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.
|
||||
// However, the empty node can't be simply removed, because:
|
||||
// 1. the following processors will still try to access it (need to double-check undefined behaviors)
|
||||
// 2. the new node is inserted as "<p>{TextBefore}<div NewNode/>{TextAfter}</p>" (the parent could also be "li")
|
||||
// then it is resolved as: "<p>{TextBefore}</p><div NewNode/><p>{TextAfter}</p>",
|
||||
// so unless it could correctly replace the parent "p/li" node, it is very difficult to eliminate the "TextBefore" empty node.
|
||||
node.Data = textBefore
|
||||
node.Parent.InsertBefore(&html.Node{Type: html.RawNode, Data: string(h)}, next)
|
||||
if textAfter != "" {
|
||||
node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next)
|
||||
}
|
||||
node = next
|
||||
}
|
||||
}
|
@ -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>`)
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
// 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/charset"
|
||||
"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, opts.LineStart)
|
||||
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 && line == "" {
|
||||
break
|
||||
} else {
|
||||
lineNums = append(lineNums, i)
|
||||
lineCodes = append(lineCodes, line)
|
||||
}
|
||||
}
|
||||
realLineStop := max(opts.LineStart, opts.LineStart+len(lineNums)-1)
|
||||
highlightLines := code.HighlightSearchResultCode(opts.FilePath, language, lineNums, strings.Join(lineCodes, ""))
|
||||
|
||||
escapeStatus := &charset.EscapeStatus{}
|
||||
lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines))
|
||||
for i, hl := range highlightLines {
|
||||
lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, webCtx.Base.Locale, charset.RuneNBSP)
|
||||
escapeStatus = escapeStatus.Or(lineEscapeStatus[i])
|
||||
}
|
||||
|
||||
return webCtx.RenderToHTML("base/markup_codepreview", map[string]any{
|
||||
"FullURL": opts.FullURL,
|
||||
"FilePath": opts.FilePath,
|
||||
"LineStart": opts.LineStart,
|
||||
"LineStop": realLineStop,
|
||||
"RepoLink": dbRepo.Link(),
|
||||
"CommitID": opts.CommitID,
|
||||
"HighlightLines": highlightLines,
|
||||
"EscapeStatus": escapeStatus,
|
||||
"LineEscapeStatus": lineEscapeStatus,
|
||||
})
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
// 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: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<div class="code-preview-container file-content">
|
||||
<div class="code-preview-header">
|
||||
<a href="http://full" class="muted" rel="nofollow">/README.md</a>
|
||||
repo.code_preview_line_from_to:1,2,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
|
||||
</div>
|
||||
<table class="file-view">
|
||||
<tbody><tr>
|
||||
<td class="lines-num"><span data-line-number="1"></span></td>
|
||||
<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></td>
|
||||
</tr><tr>
|
||||
<td class="lines-num"><span data-line-number="2"></span></td>
|
||||
<td class="lines-code chroma"><code class="code-inner"></span><span class="gh"></span></code></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
`, string(htm))
|
||||
|
||||
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,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<div class="code-preview-container file-content">
|
||||
<div class="code-preview-header">
|
||||
<a href="http://full" class="muted" rel="nofollow">/README.md</a>
|
||||
repo.code_preview_line_in:1,<a href="/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d" rel="nofollow">65f1bf27bc</a>
|
||||
</div>
|
||||
<table class="file-view">
|
||||
<tbody><tr>
|
||||
<td class="lines-num"><span data-line-number="1"></span></td>
|
||||
<td class="lines-code chroma"><code class="code-inner"><span class="gh"># repo1</code></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")
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
<div class="code-preview-container file-content">
|
||||
<div class="code-preview-header">
|
||||
<a href="{{.FullURL}}" class="muted" rel="nofollow">{{.FilePath}}</a>
|
||||
{{$link := HTMLFormat `<a href="%s/src/commit/%s" rel="nofollow">%s</a>` .RepoLink .CommitID (.CommitID | ShortSha) -}}
|
||||
{{- if eq .LineStart .LineStop -}}
|
||||
{{ctx.Locale.Tr "repo.code_preview_line_in" .LineStart $link}}
|
||||
{{- else -}}
|
||||
{{ctx.Locale.Tr "repo.code_preview_line_from_to" .LineStart .LineStop $link}}
|
||||
{{- end}}
|
||||
</div>
|
||||
<table class="file-view">
|
||||
<tbody>
|
||||
{{- range $idx, $line := .HighlightLines -}}
|
||||
<tr>
|
||||
<td class="lines-num"><span data-line-number="{{$line.Num}}"></span></td>
|
||||
{{- if $.EscapeStatus.Escaped -}}
|
||||
{{- $lineEscapeStatus := index $.LineEscapeStatus $idx -}}
|
||||
<td class="lines-escape">{{if $lineEscapeStatus.Escaped}}<a href="#" class="toggle-escape-button btn interact-bg" title="{{if $lineEscapeStatus.HasInvisible}}{{ctx.Locale.Tr "repo.invisible_runes_line"}} {{end}}{{if $lineEscapeStatus.HasAmbiguous}}{{ctx.Locale.Tr "repo.ambiguous_runes_line"}}{{end}}"></a>{{end}}</td>
|
||||
{{- end}}
|
||||
<td class="lines-code chroma"><code class="code-inner">{{$line.FormattedContent}}</code></td>
|
||||
</tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
@ -0,0 +1,36 @@
|
||||
.markup .code-preview-container {
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.markup .code-preview-container .code-preview-header {
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
padding: 0.5em;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markup .code-preview-container table {
|
||||
width: 100%;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
margin: 0; /* override ".markup table {margin}" */
|
||||
}
|
||||
|
||||
/* workaround to hide empty p before container - more details are in "html_codepreview.go" */
|
||||
.markup p:empty:has(+ .code-preview-container) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* override the polluted styles from the content.css: ".markup table ..." */
|
||||
.markup .code-preview-container table tr {
|
||||
border: 0 !important;
|
||||
}
|
||||
.markup .code-preview-container table th,
|
||||
.markup .code-preview-container table td {
|
||||
border: 0 !important;
|
||||
padding: 0 0 0 5px !important;
|
||||
}
|
||||
.markup .code-preview-container table tr:nth-child(2n) {
|
||||
background: none !important;
|
||||
}
|
Loading…
Reference in New Issue