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>
This commit is contained in:
parent
eb505b128c
commit
ca5c895efb
@ -4,6 +4,7 @@
|
|||||||
package charset
|
package charset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -156,13 +157,16 @@ func TestEscapeControlReader(t *testing.T) {
|
|||||||
tests = append(tests, test)
|
tests = append(tests, test)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`repo.ambiguous_character:\d+,\d+`) // simplify the output for the tests, remove the translation variants
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output := &strings.Builder{}
|
output := &strings.Builder{}
|
||||||
status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{})
|
status, err := EscapeControlReader(strings.NewReader(tt.text), output, &translation.MockLocale{})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.status, *status)
|
assert.Equal(t, tt.status, *status)
|
||||||
assert.Equal(t, tt.result, output.String())
|
outStr := output.String()
|
||||||
|
outStr = re.ReplaceAllString(outStr, "repo.ambiguous_character")
|
||||||
|
assert.Equal(t, tt.result, outStr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -561,14 +561,14 @@ func TestFormatError(t *testing.T) {
|
|||||||
err: &csv.ParseError{
|
err: &csv.ParseError{
|
||||||
Err: csv.ErrFieldCount,
|
Err: csv.ErrFieldCount,
|
||||||
},
|
},
|
||||||
expectedMessage: "repo.error.csv.invalid_field_count",
|
expectedMessage: "repo.error.csv.invalid_field_count:0",
|
||||||
expectsError: false,
|
expectsError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
err: &csv.ParseError{
|
err: &csv.ParseError{
|
||||||
Err: csv.ErrBareQuote,
|
Err: csv.ErrBareQuote,
|
||||||
},
|
},
|
||||||
expectedMessage: "repo.error.csv.unexpected",
|
expectedMessage: "repo.error.csv.unexpected:0,0",
|
||||||
expectsError: false,
|
expectsError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -22,7 +22,7 @@ type Result struct {
|
|||||||
UpdatedUnix timeutil.TimeStamp
|
UpdatedUnix timeutil.TimeStamp
|
||||||
Language string
|
Language string
|
||||||
Color string
|
Color string
|
||||||
Lines []ResultLine
|
Lines []*ResultLine
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResultLine struct {
|
type ResultLine struct {
|
||||||
@ -70,16 +70,18 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error {
|
|||||||
return nil
|
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
|
// 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")
|
highlightedLines := strings.Split(string(hl), "\n")
|
||||||
|
|
||||||
// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
|
// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
|
||||||
lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
|
lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums)))
|
||||||
for i := 0; i < len(lines); i++ {
|
for i := 0; i < len(lines); i++ {
|
||||||
lines[i].Num = lineNums[i]
|
lines[i] = &ResultLine{
|
||||||
lines[i].FormattedContent = template.HTML(highlightedLines[i])
|
Num: lineNums[i],
|
||||||
|
FormattedContent: template.HTML(highlightedLines[i]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@ -122,7 +124,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
|
|||||||
UpdatedUnix: result.UpdatedUnix,
|
UpdatedUnix: result.UpdatedUnix,
|
||||||
Language: result.Language,
|
Language: result.Language,
|
||||||
Color: result.Color,
|
Color: result.Color,
|
||||||
Lines: HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()),
|
Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,6 +171,7 @@ type processor func(ctx *RenderContext, node *html.Node)
|
|||||||
var defaultProcessors = []processor{
|
var defaultProcessors = []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
|
codePreviewPatternProcessor,
|
||||||
fullHashPatternProcessor,
|
fullHashPatternProcessor,
|
||||||
shortLinkProcessor,
|
shortLinkProcessor,
|
||||||
linkProcessor,
|
linkProcessor,
|
||||||
|
92
modules/markup/html_codepreview.go
Normal file
92
modules/markup/html_codepreview.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
34
modules/markup/html_codepreview_test.go
Normal file
34
modules/markup/html_codepreview_test.go
Normal file
@ -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>`)
|
||||||
|
}
|
@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -33,6 +34,8 @@ type ProcessorHelper struct {
|
|||||||
IsUsernameMentionable func(ctx context.Context, username string) bool
|
IsUsernameMentionable func(ctx context.Context, username string) bool
|
||||||
|
|
||||||
ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
|
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
|
var DefaultProcessorHelper ProcessorHelper
|
||||||
|
@ -60,6 +60,21 @@ func createDefaultPolicy() *bluemonday.Policy {
|
|||||||
// For JS code copy and Mermaid loading state
|
// 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)?$`)).OnElements("pre")
|
||||||
|
|
||||||
|
// For code preview
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-preview-[-\w]+( file-content)?$`)).Globally()
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-num$`)).OnElements("td")
|
||||||
|
policy.AllowAttrs("data-line-number").OnElements("span")
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-code chroma$`)).OnElements("td")
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-inner$`)).OnElements("code")
|
||||||
|
|
||||||
|
// For code preview (unicode escape)
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^file-view( unicode-escaped)?$`)).OnElements("table")
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^lines-escape$`)).OnElements("td")
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^toggle-escape-button btn interact-bg$`)).OnElements("a") // don't use button, button might submit a form
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ambiguous-code-point|escaped-code-point|broken-code-point)$`)).OnElements("span")
|
||||||
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^char$`)).OnElements("span")
|
||||||
|
policy.AllowAttrs("data-tooltip-content", "data-escaped").OnElements("span")
|
||||||
|
|
||||||
// For color preview
|
// For color preview
|
||||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
|
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ package translation
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockLocale provides a mocked locale without any translations
|
// MockLocale provides a mocked locale without any translations
|
||||||
@ -19,18 +20,25 @@ func (l MockLocale) Language() string {
|
|||||||
return "en"
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l MockLocale) TrString(s string, _ ...any) string {
|
func (l MockLocale) TrString(s string, args ...any) string {
|
||||||
return s
|
return sprintAny(s, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l MockLocale) Tr(s string, a ...any) template.HTML {
|
func (l MockLocale) Tr(s string, args ...any) template.HTML {
|
||||||
return template.HTML(s)
|
return template.HTML(sprintAny(s, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
|
func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
|
||||||
return template.HTML(key1)
|
return template.HTML(sprintAny(key1, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l MockLocale) PrettyNumber(v any) string {
|
func (l MockLocale) PrettyNumber(v any) string {
|
||||||
return fmt.Sprint(v)
|
return fmt.Sprint(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sprintAny(s string, args ...any) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + ":" + fmt.Sprintf(strings.Repeat(",%v", len(args))[1:], args...)
|
||||||
|
}
|
||||||
|
@ -1233,6 +1233,8 @@ file_view_rendered = View Rendered
|
|||||||
file_view_raw = View Raw
|
file_view_raw = View Raw
|
||||||
file_permalink = Permalink
|
file_permalink = Permalink
|
||||||
file_too_large = The file is too large to be shown.
|
file_too_large = The file is too large to be shown.
|
||||||
|
code_preview_line_from_to = Lines %[1]d to %[2]d in %[3]s
|
||||||
|
code_preview_line_in = Line %[1]d in %[2]s
|
||||||
invisible_runes_header = `This file contains invisible Unicode characters`
|
invisible_runes_header = `This file contains invisible Unicode characters`
|
||||||
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.`
|
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.`
|
||||||
ambiguous_runes_header = `This file contains ambiguous Unicode characters`
|
ambiguous_runes_header = `This file contains ambiguous Unicode characters`
|
||||||
|
@ -81,7 +81,7 @@ func Search(ctx *context.Context) {
|
|||||||
// UpdatedUnix: not supported yet
|
// UpdatedUnix: not supported yet
|
||||||
// Language: not supported yet
|
// Language: not supported yet
|
||||||
// Color: 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")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -145,7 +145,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
NewWikiPost(ctx)
|
NewWikiPost(ctx)
|
||||||
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
|
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
|
||||||
assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg)
|
assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg)
|
||||||
assertWikiNotExists(t, ctx.Repo.Repository, "_edit")
|
assertWikiNotExists(t, ctx.Repo.Repository, "_edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,6 +63,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont
|
|||||||
base.Locale = &translation.MockLocale{}
|
base.Locale = &translation.MockLocale{}
|
||||||
|
|
||||||
ctx := context.NewWebContext(base, opt.Render, nil)
|
ctx := context.NewWebContext(base, opt.Render, nil)
|
||||||
|
ctx.AppendContextValue(context.WebContextKey, ctx)
|
||||||
ctx.PageData = map[string]any{}
|
ctx.PageData = map[string]any{}
|
||||||
ctx.Data["PageStartTime"] = time.Now()
|
ctx.Data["PageStartTime"] = time.Now()
|
||||||
chiCtx := chi.NewRouteContext()
|
chiCtx := chi.NewRouteContext()
|
||||||
|
@ -11,6 +11,6 @@ import (
|
|||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
unittest.MainTest(m, &unittest.TestOptions{
|
unittest.MainTest(m, &unittest.TestOptions{
|
||||||
FixtureFiles: []string{"user.yml"},
|
FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,8 @@ import (
|
|||||||
func ProcessorHelper() *markup.ProcessorHelper {
|
func ProcessorHelper() *markup.ProcessorHelper {
|
||||||
return &markup.ProcessorHelper{
|
return &markup.ProcessorHelper{
|
||||||
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
|
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
|
||||||
|
|
||||||
|
RenderRepoFileCodePreview: renderRepoFileCodePreview,
|
||||||
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||||
mentionedUser, err := user.GetUserByName(ctx, username)
|
mentionedUser, err := user.GetUserByName(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
117
services/markup/processorhelper_codepreview.go
Normal file
117
services/markup/processorhelper_codepreview.go
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
}
|
83
services/markup/processorhelper_codepreview_test.go
Normal file
83
services/markup/processorhelper_codepreview_test.go
Normal file
@ -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")
|
||||||
|
}
|
25
templates/base/markup_codepreview.tmpl
Normal file
25
templates/base/markup_codepreview.tmpl
Normal file
@ -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>
|
@ -1186,10 +1186,13 @@ overflow-menu .ui.label {
|
|||||||
content: attr(data-line-number);
|
content: attr(data-line-number);
|
||||||
line-height: 20px !important;
|
line-height: 20px !important;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-view .lines-num span::after {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.lines-type-marker {
|
.lines-type-marker {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
@import "./markup/content.css";
|
@import "./markup/content.css";
|
||||||
@import "./markup/codecopy.css";
|
@import "./markup/codecopy.css";
|
||||||
|
@import "./markup/codepreview.css";
|
||||||
@import "./markup/asciicast.css";
|
@import "./markup/asciicast.css";
|
||||||
|
|
||||||
@import "./chroma/base.css";
|
@import "./chroma/base.css";
|
||||||
|
36
web_src/css/markup/codepreview.css
Normal file
36
web_src/css/markup/codepreview.css
Normal file
@ -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;
|
||||||
|
}
|
@ -382,7 +382,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markup span.align-center span img
|
.markup span.align-center span img,
|
||||||
.markup span.align-center span video {
|
.markup span.align-center span video {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -432,7 +432,7 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markup code,
|
.markup code:not(.code-inner),
|
||||||
.markup tt {
|
.markup tt {
|
||||||
padding: 0.2em 0.4em;
|
padding: 0.2em 0.4em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
Loading…
Reference in New Issue
Block a user