diff --git a/modules/markup/file_preview.go b/modules/markup/file_preview.go new file mode 100644 index 0000000000..646bf83630 --- /dev/null +++ b/modules/markup/file_preview.go @@ -0,0 +1,269 @@ +package markup + +import ( + "bytes" + "html/template" + "regexp" + "slices" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +var ( + // filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2" + filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`) +) + +type FilePreview struct { + fileContent []template.HTML + subTitle template.HTML + lineOffset int + urlFull string + filePath string + start int + end int +} + +func NewFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale) *FilePreview { + preview := &FilePreview{} + + m := filePreviewPattern.FindStringSubmatchIndex(node.Data) + if m == nil { + return nil + } + + // Ensure that every group has a match + if slices.Contains(m, -1) { + return nil + } + + preview.urlFull = node.Data[m[0]:m[1]] + + // Ensure that we only use links to local repositories + if !strings.HasPrefix(preview.urlFull, setting.AppURL+setting.AppSubURL) { + return nil + } + + projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/") + + commitSha := node.Data[m[4]:m[5]] + preview.filePath = node.Data[m[6]:m[7]] + hash := node.Data[m[8]:m[9]] + + preview.start = m[0] + preview.end = m[1] + + // If url ends in '.', it's very likely that it is not part of the + // actual url but used to finish a sentence. + if strings.HasSuffix(preview.urlFull, ".") { + preview.end-- + preview.urlFull = preview.urlFull[:len(preview.urlFull)-1] + hash = hash[:len(hash)-1] + } + + projPathSegments := strings.Split(projPath, "/") + fileContent, err := DefaultProcessorHelper.GetRepoFileContent( + ctx.Ctx, + projPathSegments[len(projPathSegments)-2], + projPathSegments[len(projPathSegments)-1], + commitSha, preview.filePath, + ) + if err != nil { + return nil + } + + lineSpecs := strings.Split(hash, "-") + lineCount := len(fileContent) + + commitLinkBuffer := new(bytes.Buffer) + html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black")) + + if len(lineSpecs) == 1 { + line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) + if line < 1 || line > lineCount { + return nil + } + + preview.fileContent = fileContent[line-1 : line] + preview.subTitle = locale.Tr( + "markup.filepreview.line", line, + template.HTML(commitLinkBuffer.String()), + ) + + preview.lineOffset = line - 1 + } else { + startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) + endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L")) + + if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine { + return nil + } + + preview.fileContent = fileContent[startLine-1 : endLine] + preview.subTitle = locale.Tr( + "markup.filepreview.lines", startLine, endLine, + template.HTML(commitLinkBuffer.String()), + ) + + preview.lineOffset = startLine - 1 + } + + return preview +} + +func (p *FilePreview) CreateHtml(locale translation.Locale) *html.Node { + table := &html.Node{ + Type: html.ElementNode, + Data: atom.Table.String(), + Attr: []html.Attribute{{Key: "class", Val: "file-preview"}}, + } + tbody := &html.Node{ + Type: html.ElementNode, + Data: atom.Tbody.String(), + } + + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(p.fileContent)) + for i, line := range p.fileContent { + statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext) + status = status.Or(statuses[i]) + } + + for idx, code := range p.fileContent { + tr := &html.Node{ + Type: html.ElementNode, + Data: atom.Tr.String(), + } + + lineNum := strconv.Itoa(p.lineOffset + idx + 1) + + tdLinesnum := &html.Node{ + Type: html.ElementNode, + Data: atom.Td.String(), + Attr: []html.Attribute{ + {Key: "id", Val: "L" + lineNum}, + {Key: "class", Val: "lines-num"}, + }, + } + spanLinesNum := &html.Node{ + Type: html.ElementNode, + Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: "id", Val: "L" + lineNum}, + {Key: "data-line-number", Val: lineNum}, + }, + } + tdLinesnum.AppendChild(spanLinesNum) + tr.AppendChild(tdLinesnum) + + if status.Escaped { + tdLinesEscape := &html.Node{ + Type: html.ElementNode, + Data: atom.Td.String(), + Attr: []html.Attribute{ + {Key: "class", Val: "lines-escape"}, + }, + } + + if statuses[idx].Escaped { + btnTitle := "" + if statuses[idx].HasInvisible { + btnTitle += locale.TrString("repo.invisible_runes_line") + " " + } + if statuses[idx].HasAmbiguous { + btnTitle += locale.TrString("repo.ambiguous_runes_line") + } + + escapeBtn := &html.Node{ + Type: html.ElementNode, + Data: atom.Button.String(), + Attr: []html.Attribute{ + {Key: "class", Val: "toggle-escape-button btn interact-bg"}, + {Key: "title", Val: btnTitle}, + }, + } + tdLinesEscape.AppendChild(escapeBtn) + } + + tr.AppendChild(tdLinesEscape) + } + + tdCode := &html.Node{ + Type: html.ElementNode, + Data: atom.Td.String(), + Attr: []html.Attribute{ + {Key: "rel", Val: "L" + lineNum}, + {Key: "class", Val: "lines-code chroma"}, + }, + } + codeInner := &html.Node{ + Type: html.ElementNode, + Data: atom.Code.String(), + Attr: []html.Attribute{{Key: "class", Val: "code-inner"}}, + } + codeText := &html.Node{ + Type: html.RawNode, + Data: string(code), + } + codeInner.AppendChild(codeText) + tdCode.AppendChild(codeInner) + tr.AppendChild(tdCode) + + tbody.AppendChild(tr) + } + + table.AppendChild(tbody) + + twrapper := &html.Node{ + Type: html.ElementNode, + Data: atom.Div.String(), + Attr: []html.Attribute{{Key: "class", Val: "ui table"}}, + } + twrapper.AppendChild(table) + + header := &html.Node{ + Type: html.ElementNode, + Data: atom.Div.String(), + Attr: []html.Attribute{{Key: "class", Val: "header"}}, + } + afilepath := &html.Node{ + Type: html.ElementNode, + Data: atom.A.String(), + Attr: []html.Attribute{ + {Key: "href", Val: p.urlFull}, + {Key: "class", Val: "muted"}, + }, + } + afilepath.AppendChild(&html.Node{ + Type: html.TextNode, + Data: p.filePath, + }) + header.AppendChild(afilepath) + + psubtitle := &html.Node{ + Type: html.ElementNode, + Data: atom.Span.String(), + Attr: []html.Attribute{{Key: "class", Val: "text small grey"}}, + } + psubtitle.AppendChild(&html.Node{ + Type: html.RawNode, + Data: string(p.subTitle), + }) + header.AppendChild(psubtitle) + + preview_node := &html.Node{ + Type: html.ElementNode, + Data: atom.Div.String(), + Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}}, + } + preview_node.AppendChild(header) + preview_node.AppendChild(twrapper) + + return preview_node +} diff --git a/modules/markup/html.go b/modules/markup/html.go index 1e83dad701..2e38c05f58 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -5,19 +5,15 @@ package markup import ( "bytes" - "html/template" "io" "net/url" "path" "path/filepath" "regexp" - "slices" - "strconv" "strings" "sync" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -65,9 +61,6 @@ var ( validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`) - // filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2" - filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`) - // While this email regex is definitely not perfect and I'm sure you can come up // with edge cases, it is still accepted by the CommonMark specification, as // well as the HTML5 spec: @@ -1072,252 +1065,28 @@ func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { next := node.NextSibling for node != nil && node != next { - m := filePreviewPattern.FindStringSubmatchIndex(node.Data) - if m == nil { - return - } - - // Ensure that every group has a match - if slices.Contains(m, -1) { - return - } - - urlFull := node.Data[m[0]:m[1]] - - // Ensure that we only use links to local repositories - if !strings.HasPrefix(urlFull, setting.AppURL+setting.AppSubURL) { - return - } - - projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/") - - commitSha := node.Data[m[4]:m[5]] - filePath := node.Data[m[6]:m[7]] - hash := node.Data[m[8]:m[9]] - - start := m[0] - end := m[1] - - // If url ends in '.', it's very likely that it is not part of the - // actual url but used to finish a sentence. - if strings.HasSuffix(urlFull, ".") { - end-- - urlFull = urlFull[:len(urlFull)-1] - hash = hash[:len(hash)-1] - } - - projPathSegments := strings.Split(projPath, "/") - fileContent, err := DefaultProcessorHelper.GetRepoFileContent( - ctx.Ctx, - projPathSegments[len(projPathSegments)-2], - projPathSegments[len(projPathSegments)-1], - commitSha, filePath, - ) - if err != nil { - return - } - - lineSpecs := strings.Split(hash, "-") - lineCount := len(fileContent) - - commitLinkBuffer := new(bytes.Buffer) - html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black")) - - var subTitle template.HTML - var lineOffset int - locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale) if !ok { locale = translation.NewLocale("en-US") } - if len(lineSpecs) == 1 { - line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) - if line < 1 || line > lineCount { - return - } - - fileContent = fileContent[line-1 : line] - subTitle = locale.Tr( - "markup.filepreview.line", line, - template.HTML(commitLinkBuffer.String()), - ) - - lineOffset = line - 1 - } else { - startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L")) - endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L")) - - if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine { - return - } - - fileContent = fileContent[startLine-1 : endLine] - subTitle = locale.Tr( - "markup.filepreview.lines", startLine, endLine, - template.HTML(commitLinkBuffer.String()), - ) - - lineOffset = startLine - 1 + preview := NewFilePreview(ctx, node, locale) + if preview == nil { + return } - table := &html.Node{ - Type: html.ElementNode, - Data: atom.Table.String(), - Attr: []html.Attribute{{Key: "class", Val: "file-preview"}}, - } - tbody := &html.Node{ - Type: html.ElementNode, - Data: atom.Tbody.String(), - } - - status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext) - status = status.Or(statuses[i]) - } - - for idx, code := range fileContent { - tr := &html.Node{ - Type: html.ElementNode, - Data: atom.Tr.String(), - } - - lineNum := strconv.Itoa(lineOffset + idx + 1) - - tdLinesnum := &html.Node{ - Type: html.ElementNode, - Data: atom.Td.String(), - Attr: []html.Attribute{ - {Key: "id", Val: "L" + lineNum}, - {Key: "class", Val: "lines-num"}, - }, - } - spanLinesNum := &html.Node{ - Type: html.ElementNode, - Data: atom.Span.String(), - Attr: []html.Attribute{ - {Key: "id", Val: "L" + lineNum}, - {Key: "data-line-number", Val: lineNum}, - }, - } - tdLinesnum.AppendChild(spanLinesNum) - tr.AppendChild(tdLinesnum) - - if status.Escaped { - tdLinesEscape := &html.Node{ - Type: html.ElementNode, - Data: atom.Td.String(), - Attr: []html.Attribute{ - {Key: "class", Val: "lines-escape"}, - }, - } - - if statuses[idx].Escaped { - btnTitle := "" - if statuses[idx].HasInvisible { - btnTitle += locale.TrString("repo.invisible_runes_line") + " " - } - if statuses[idx].HasAmbiguous { - btnTitle += locale.TrString("repo.ambiguous_runes_line") - } - - escapeBtn := &html.Node{ - Type: html.ElementNode, - Data: atom.Button.String(), - Attr: []html.Attribute{ - {Key: "class", Val: "toggle-escape-button btn interact-bg"}, - {Key: "title", Val: btnTitle}, - }, - } - tdLinesEscape.AppendChild(escapeBtn) - } - - tr.AppendChild(tdLinesEscape) - } - - tdCode := &html.Node{ - Type: html.ElementNode, - Data: atom.Td.String(), - Attr: []html.Attribute{ - {Key: "rel", Val: "L" + lineNum}, - {Key: "class", Val: "lines-code chroma"}, - }, - } - codeInner := &html.Node{ - Type: html.ElementNode, - Data: atom.Code.String(), - Attr: []html.Attribute{{Key: "class", Val: "code-inner"}}, - } - codeText := &html.Node{ - Type: html.RawNode, - Data: string(code), - } - codeInner.AppendChild(codeText) - tdCode.AppendChild(codeInner) - tr.AppendChild(tdCode) - - tbody.AppendChild(tr) - } - - table.AppendChild(tbody) - - twrapper := &html.Node{ - Type: html.ElementNode, - Data: atom.Div.String(), - Attr: []html.Attribute{{Key: "class", Val: "ui table"}}, - } - twrapper.AppendChild(table) - - header := &html.Node{ - Type: html.ElementNode, - Data: atom.Div.String(), - Attr: []html.Attribute{{Key: "class", Val: "header"}}, - } - afilepath := &html.Node{ - Type: html.ElementNode, - Data: atom.A.String(), - Attr: []html.Attribute{ - {Key: "href", Val: urlFull}, - {Key: "class", Val: "muted"}, - }, - } - afilepath.AppendChild(&html.Node{ - Type: html.TextNode, - Data: filePath, - }) - header.AppendChild(afilepath) - - psubtitle := &html.Node{ - Type: html.ElementNode, - Data: atom.Span.String(), - Attr: []html.Attribute{{Key: "class", Val: "text small grey"}}, - } - psubtitle.AppendChild(&html.Node{ - Type: html.RawNode, - Data: string(subTitle), - }) - header.AppendChild(psubtitle) - - preview := &html.Node{ - Type: html.ElementNode, - Data: atom.Div.String(), - Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}}, - } - preview.AppendChild(header) - preview.AppendChild(twrapper) + preview_node := preview.CreateHtml(locale) // Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div - before := node.Data[:start] - after := node.Data[end:] + before := node.Data[:preview.start] + after := node.Data[preview.end:] node.Data = before nextSibling := node.NextSibling node.Parent.InsertBefore(&html.Node{ Type: html.RawNode, Data: "

", }, nextSibling) - node.Parent.InsertBefore(preview, nextSibling) + node.Parent.InsertBefore(preview_node, nextSibling) node.Parent.InsertBefore(&html.Node{ Type: html.RawNode, Data: "

" + after,