Skip to content

Commit 116afdc

Browse files
committed
Render inline code references
1 parent 12865ae commit 116afdc

File tree

7 files changed

+420
-11
lines changed

7 files changed

+420
-11
lines changed

modules/markup/html.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010
"path"
1111
"path/filepath"
1212
"regexp"
13+
"strconv"
1314
"strings"
1415
"sync"
1516

1617
"code.gitea.io/gitea/modules/base"
18+
"code.gitea.io/gitea/modules/charset"
1719
"code.gitea.io/gitea/modules/emoji"
1820
"code.gitea.io/gitea/modules/git"
1921
"code.gitea.io/gitea/modules/log"
@@ -59,6 +61,9 @@ var (
5961
// comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
6062
comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
6163

64+
// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
65+
filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{7,64})/(\S+)#(L\d+(?:-L\d+)?)`)
66+
6267
validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
6368

6469
// While this email regex is definitely not perfect and I'm sure you can come up
@@ -171,6 +176,7 @@ type processor func(ctx *RenderContext, node *html.Node)
171176
var defaultProcessors = []processor{
172177
fullIssuePatternProcessor,
173178
comparePatternProcessor,
179+
filePreviewPatternProcessor,
174180
fullHashPatternProcessor,
175181
shortLinkProcessor,
176182
linkProcessor,
@@ -1054,6 +1060,266 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
10541060
}
10551061
}
10561062

1063+
func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
1064+
if ctx.Metas == nil {
1065+
return
1066+
}
1067+
if DefaultProcessorHelper.GetRepoFileContent == nil || DefaultProcessorHelper.GetLocale == nil {
1068+
return
1069+
}
1070+
1071+
next := node.NextSibling
1072+
for node != nil && node != next {
1073+
m := filePreviewPattern.FindStringSubmatchIndex(node.Data)
1074+
if m == nil {
1075+
return
1076+
}
1077+
1078+
// Ensure that every group (m[0]...m[9]) has a match
1079+
for i := 0; i < 10; i++ {
1080+
if m[i] == -1 {
1081+
return
1082+
}
1083+
}
1084+
1085+
urlFull := node.Data[m[0]:m[1]]
1086+
1087+
// Ensure that we only use links to local repositories
1088+
if !strings.HasPrefix(urlFull, setting.AppURL+setting.AppSubURL) {
1089+
return
1090+
}
1091+
1092+
projPath := node.Data[m[2]:m[3]]
1093+
projPath = strings.TrimSuffix(projPath, "/")
1094+
1095+
commitSha := node.Data[m[4]:m[5]]
1096+
filePath := node.Data[m[6]:m[7]]
1097+
hash := node.Data[m[8]:m[9]]
1098+
1099+
start := m[0]
1100+
end := m[1]
1101+
1102+
// If url ends in '.', it's very likely that it is not part of the
1103+
// actual url but used to finish a sentence.
1104+
if strings.HasSuffix(urlFull, ".") {
1105+
end--
1106+
urlFull = urlFull[:len(urlFull)-1]
1107+
hash = hash[:len(hash)-1]
1108+
}
1109+
1110+
projPathSegments := strings.Split(projPath, "/")
1111+
fileContent, err := DefaultProcessorHelper.GetRepoFileContent(
1112+
ctx.Ctx,
1113+
projPathSegments[len(projPathSegments)-2],
1114+
projPathSegments[len(projPathSegments)-1],
1115+
commitSha, filePath,
1116+
)
1117+
if err != nil {
1118+
return
1119+
}
1120+
1121+
lineSpecs := strings.Split(hash, "-")
1122+
lineCount := len(fileContent)
1123+
1124+
var subTitle string
1125+
var lineOffset int
1126+
1127+
if len(lineSpecs) == 1 {
1128+
line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
1129+
if line < 1 || line > lineCount {
1130+
return
1131+
}
1132+
1133+
fileContent = fileContent[line-1 : line]
1134+
subTitle = "Line " + strconv.Itoa(line)
1135+
1136+
lineOffset = line - 1
1137+
} else {
1138+
startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
1139+
endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
1140+
1141+
if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine {
1142+
return
1143+
}
1144+
1145+
fileContent = fileContent[startLine-1 : endLine]
1146+
subTitle = "Lines " + strconv.Itoa(startLine) + " to " + strconv.Itoa(endLine)
1147+
1148+
lineOffset = startLine - 1
1149+
}
1150+
1151+
table := &html.Node{
1152+
Type: html.ElementNode,
1153+
Data: atom.Table.String(),
1154+
Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
1155+
}
1156+
tbody := &html.Node{
1157+
Type: html.ElementNode,
1158+
Data: atom.Tbody.String(),
1159+
}
1160+
1161+
locale, err := DefaultProcessorHelper.GetLocale(ctx.Ctx)
1162+
if err != nil {
1163+
return
1164+
}
1165+
1166+
status := &charset.EscapeStatus{}
1167+
statuses := make([]*charset.EscapeStatus, len(fileContent))
1168+
for i, line := range fileContent {
1169+
statuses[i], fileContent[i] = charset.EscapeControlHTML(line, locale)
1170+
status = status.Or(statuses[i])
1171+
}
1172+
1173+
for idx, code := range fileContent {
1174+
tr := &html.Node{
1175+
Type: html.ElementNode,
1176+
Data: atom.Tr.String(),
1177+
}
1178+
1179+
lineNum := strconv.Itoa(lineOffset + idx + 1)
1180+
1181+
tdLinesnum := &html.Node{
1182+
Type: html.ElementNode,
1183+
Data: atom.Td.String(),
1184+
Attr: []html.Attribute{
1185+
{Key: "id", Val: "L" + lineNum},
1186+
{Key: "class", Val: "lines-num"},
1187+
},
1188+
}
1189+
spanLinesNum := &html.Node{
1190+
Type: html.ElementNode,
1191+
Data: atom.Span.String(),
1192+
Attr: []html.Attribute{
1193+
{Key: "id", Val: "L" + lineNum},
1194+
{Key: "data-line-number", Val: lineNum},
1195+
},
1196+
}
1197+
tdLinesnum.AppendChild(spanLinesNum)
1198+
tr.AppendChild(tdLinesnum)
1199+
1200+
if status.Escaped {
1201+
tdLinesEscape := &html.Node{
1202+
Type: html.ElementNode,
1203+
Data: atom.Td.String(),
1204+
Attr: []html.Attribute{
1205+
{Key: "class", Val: "lines-escape"},
1206+
},
1207+
}
1208+
1209+
if statuses[idx].Escaped {
1210+
btnTitle := ""
1211+
if statuses[idx].HasInvisible {
1212+
btnTitle += locale.Tr("repo.invisible_runes_line") + " "
1213+
}
1214+
if statuses[idx].HasAmbiguous {
1215+
btnTitle += locale.Tr("repo.ambiguous_runes_line")
1216+
}
1217+
1218+
escapeBtn := &html.Node{
1219+
Type: html.ElementNode,
1220+
Data: atom.A.String(),
1221+
Attr: []html.Attribute{
1222+
{Key: "class", Val: "toggle-escape-button btn interact-bg"},
1223+
{Key: "title", Val: btnTitle},
1224+
{Key: "href", Val: "javascript:void(0)"},
1225+
},
1226+
}
1227+
tdLinesEscape.AppendChild(escapeBtn)
1228+
}
1229+
1230+
tr.AppendChild(tdLinesEscape)
1231+
}
1232+
1233+
tdCode := &html.Node{
1234+
Type: html.ElementNode,
1235+
Data: atom.Td.String(),
1236+
Attr: []html.Attribute{
1237+
{Key: "rel", Val: "L" + lineNum},
1238+
{Key: "class", Val: "lines-code chroma"},
1239+
},
1240+
}
1241+
codeInner := &html.Node{
1242+
Type: html.ElementNode,
1243+
Data: atom.Code.String(),
1244+
Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
1245+
}
1246+
codeText := &html.Node{
1247+
Type: html.RawNode,
1248+
Data: string(code),
1249+
}
1250+
codeInner.AppendChild(codeText)
1251+
tdCode.AppendChild(codeInner)
1252+
tr.AppendChild(tdCode)
1253+
1254+
tbody.AppendChild(tr)
1255+
}
1256+
1257+
table.AppendChild(tbody)
1258+
1259+
twrapper := &html.Node{
1260+
Type: html.ElementNode,
1261+
Data: atom.Div.String(),
1262+
Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
1263+
}
1264+
twrapper.AppendChild(table)
1265+
1266+
header := &html.Node{
1267+
Type: html.ElementNode,
1268+
Data: atom.Div.String(),
1269+
Attr: []html.Attribute{{Key: "class", Val: "header"}},
1270+
}
1271+
afilepath := &html.Node{
1272+
Type: html.ElementNode,
1273+
Data: atom.A.String(),
1274+
Attr: []html.Attribute{{Key: "href", Val: urlFull}},
1275+
}
1276+
afilepath.AppendChild(&html.Node{
1277+
Type: html.TextNode,
1278+
Data: filePath,
1279+
})
1280+
header.AppendChild(afilepath)
1281+
1282+
psubtitle := &html.Node{
1283+
Type: html.ElementNode,
1284+
Data: atom.Span.String(),
1285+
Attr: []html.Attribute{{Key: "class", Val: "text small grey"}},
1286+
}
1287+
psubtitle.AppendChild(&html.Node{
1288+
Type: html.TextNode,
1289+
Data: subTitle + " in ",
1290+
})
1291+
psubtitle.AppendChild(createLink(urlFull[m[0]:m[5]], commitSha[0:7], ""))
1292+
header.AppendChild(psubtitle)
1293+
1294+
preview := &html.Node{
1295+
Type: html.ElementNode,
1296+
Data: atom.Div.String(),
1297+
Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
1298+
}
1299+
preview.AppendChild(header)
1300+
preview.AppendChild(twrapper)
1301+
1302+
// Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div
1303+
before := node.Data[:start]
1304+
after := node.Data[end:]
1305+
node.Data = before
1306+
nextSibling := node.NextSibling
1307+
node.Parent.InsertBefore(&html.Node{
1308+
Type: html.RawNode,
1309+
Data: "</p>",
1310+
}, nextSibling)
1311+
node.Parent.InsertBefore(preview, nextSibling)
1312+
if after != "" {
1313+
node.Parent.InsertBefore(&html.Node{
1314+
Type: html.RawNode,
1315+
Data: "<p>" + after,
1316+
}, nextSibling)
1317+
}
1318+
1319+
node = node.NextSibling
1320+
}
1321+
}
1322+
10571323
// emojiShortCodeProcessor for rendering text like :smile: into emoji
10581324
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
10591325
start := 0

modules/markup/renderer.go

Lines changed: 4 additions & 0 deletions
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"
@@ -16,6 +17,7 @@ import (
1617

1718
"code.gitea.io/gitea/modules/git"
1819
"code.gitea.io/gitea/modules/setting"
20+
"code.gitea.io/gitea/modules/translation"
1921
"code.gitea.io/gitea/modules/util"
2022

2123
"github.com/yuin/goldmark/ast"
@@ -31,6 +33,8 @@ const (
3133

3234
type ProcessorHelper struct {
3335
IsUsernameMentionable func(ctx context.Context, username string) bool
36+
GetRepoFileContent func(ctx context.Context, ownerName, repoName, commitSha, filePath string) ([]template.HTML, error)
37+
GetLocale func(ctx context.Context) (translation.Locale, error)
3438

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

modules/markup/sanitizer.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ func createDefaultPolicy() *bluemonday.Policy {
120120
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
121121
policy.AllowStyles("color", "background-color").OnElements("span", "p")
122122

123+
// Allow classes for file preview links...
124+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^(lines-num|lines-code chroma)$")).OnElements("td")
125+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^code-inner$")).OnElements("code")
126+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview-box$")).OnElements("div")
127+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui table$")).OnElements("div")
128+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^header$")).OnElements("div")
129+
policy.AllowAttrs("data-line-number").OnElements("span")
130+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^text small grey$")).OnElements("span")
131+
policy.AllowAttrs("rel").OnElements("td")
132+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview*")).OnElements("table")
133+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^lines-escape$")).OnElements("td")
134+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^toggle-escape-button btn interact-bg$")).OnElements("a")
135+
policy.AllowAttrs("class").Matching(regexp.MustCompile("^ambiguous-code-point$")).OnElements("span")
136+
policy.AllowAttrs("data-tooltip-content").OnElements("span")
137+
123138
// Allow generally safe attributes
124139
generalSafeAttrs := []string{
125140
"abbr", "accept", "accept-charset",

0 commit comments

Comments
 (0)