mirror of https://github.com/go-gitea/gitea
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