初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package asciicast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(Renderer{})
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for asciicast files.
|
||||
// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
|
||||
type Renderer struct{}
|
||||
|
||||
func (Renderer) Name() string {
|
||||
return "asciicast"
|
||||
}
|
||||
|
||||
func (Renderer) FileNamePatterns() []string {
|
||||
return []string{"*.cast"}
|
||||
}
|
||||
|
||||
const (
|
||||
playerClassName = "asciinema-player-container"
|
||||
playerSrcAttr = "data-asciinema-player-src"
|
||||
)
|
||||
|
||||
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}}
|
||||
}
|
||||
|
||||
func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
|
||||
rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
|
||||
setting.AppSubURL,
|
||||
url.PathEscape(ctx.RenderOptions.Metas["user"]),
|
||||
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
|
||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
||||
url.PathEscape(ctx.RenderOptions.RelativePath),
|
||||
)
|
||||
return ctx.RenderInternal.FormatWithSafeAttrs(output, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// CamoEncode encodes a lnk to fit with the go-camo and camo proxy links. The purposes of camo-proxy are:
|
||||
// 1. Allow accessing "http://" images on a HTTPS site by using the "https://" URLs provided by camo-proxy.
|
||||
// 2. Hide the visitor's real IP (protect privacy) when accessing external images.
|
||||
func CamoEncode(link string) string {
|
||||
if strings.HasPrefix(link, setting.Camo.ServerURL) {
|
||||
return link
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(setting.Camo.HMACKey))
|
||||
_, _ = mac.Write([]byte(link)) // hmac does not return errors
|
||||
macSum := b64encode(mac.Sum(nil))
|
||||
encodedURL := b64encode([]byte(link))
|
||||
|
||||
return strings.TrimSuffix(setting.Camo.ServerURL, "/") + "/" + macSum + "/" + encodedURL
|
||||
}
|
||||
|
||||
func b64encode(data []byte) string {
|
||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||
}
|
||||
|
||||
func camoHandleLink(link string) string {
|
||||
if setting.Camo.Enabled {
|
||||
lnkURL, err := url.Parse(link)
|
||||
if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) &&
|
||||
(setting.Camo.Always || lnkURL.Scheme != "https") {
|
||||
return CamoEncode(link)
|
||||
}
|
||||
}
|
||||
return link
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCamoHandleLink(t *testing.T) {
|
||||
setting.AppURL = "https://gitea.com"
|
||||
// Test media proxy
|
||||
setting.Camo.Enabled = true
|
||||
setting.Camo.ServerURL = "https://image.proxy"
|
||||
setting.Camo.HMACKey = "geheim"
|
||||
|
||||
assert.Equal(t,
|
||||
"https://gitea.com/img.jpg",
|
||||
camoHandleLink("https://gitea.com/img.jpg"))
|
||||
assert.Equal(t,
|
||||
"https://testimages.org/img.jpg",
|
||||
camoHandleLink("https://testimages.org/img.jpg"))
|
||||
assert.Equal(t,
|
||||
"https://image.proxy/eivin43gJwGVIjR9MiYYtFIk0mw/aHR0cDovL3Rlc3RpbWFnZXMub3JnL2ltZy5qcGc",
|
||||
camoHandleLink("http://testimages.org/img.jpg"))
|
||||
|
||||
setting.Camo.Always = true
|
||||
assert.Equal(t,
|
||||
"https://gitea.com/img.jpg",
|
||||
camoHandleLink("https://gitea.com/img.jpg"))
|
||||
assert.Equal(t,
|
||||
"https://image.proxy/tkdlvmqpbIr7SjONfHNgEU622y0/aHR0cHM6Ly90ZXN0aW1hZ2VzLm9yZy9pbWcuanBn",
|
||||
camoHandleLink("https://testimages.org/img.jpg"))
|
||||
assert.Equal(t,
|
||||
"https://image.proxy/eivin43gJwGVIjR9MiYYtFIk0mw/aHR0cDovL3Rlc3RpbWFnZXMub3JnL2ltZy5qcGc",
|
||||
camoHandleLink("http://testimages.org/img.jpg"))
|
||||
|
||||
// Restore previous settings
|
||||
setting.Camo.Enabled = false
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// Copyright 2019 Yusuke Inuzuka
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"unicode"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// CleanValue will clean a value to make it safe to be an id
|
||||
// This function is quite different from the original goldmark function
|
||||
// and more closely matches the output from the shurcooL sanitizer
|
||||
// In particular Unicode letters and numbers are a lot more than a-zA-Z0-9...
|
||||
func CleanValue(value []byte) []byte {
|
||||
value = bytes.TrimSpace(value)
|
||||
rs := bytes.Runes(value)
|
||||
result := make([]rune, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' {
|
||||
result = append(result, unicode.ToLower(r))
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
result = append(result, '-')
|
||||
}
|
||||
}
|
||||
return []byte(string(result))
|
||||
}
|
||||
|
||||
// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
|
||||
|
||||
// A FootnoteLink struct represents a link to a footnote of Markdown
|
||||
// (PHP Markdown Extra) text.
|
||||
type FootnoteLink struct {
|
||||
ast.BaseInline
|
||||
Index int
|
||||
Name []byte
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *FootnoteLink) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Index"] = strconv.Itoa(n.Index)
|
||||
m["Name"] = fmt.Sprintf("%v", n.Name)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindFootnoteLink is a NodeKind of the FootnoteLink node.
|
||||
var KindFootnoteLink = ast.NewNodeKind("GiteaFootnoteLink")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *FootnoteLink) Kind() ast.NodeKind {
|
||||
return KindFootnoteLink
|
||||
}
|
||||
|
||||
// NewFootnoteLink returns a new FootnoteLink node.
|
||||
func NewFootnoteLink(index int, name []byte) *FootnoteLink {
|
||||
return &FootnoteLink{
|
||||
Index: index,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// A FootnoteBackLink struct represents a link to a footnote of Markdown
|
||||
// (PHP Markdown Extra) text.
|
||||
type FootnoteBackLink struct {
|
||||
ast.BaseInline
|
||||
Index int
|
||||
Name []byte
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *FootnoteBackLink) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Index"] = strconv.Itoa(n.Index)
|
||||
m["Name"] = fmt.Sprintf("%v", n.Name)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindFootnoteBackLink is a NodeKind of the FootnoteBackLink node.
|
||||
var KindFootnoteBackLink = ast.NewNodeKind("GiteaFootnoteBackLink")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *FootnoteBackLink) Kind() ast.NodeKind {
|
||||
return KindFootnoteBackLink
|
||||
}
|
||||
|
||||
// NewFootnoteBackLink returns a new FootnoteBackLink node.
|
||||
func NewFootnoteBackLink(index int, name []byte) *FootnoteBackLink {
|
||||
return &FootnoteBackLink{
|
||||
Index: index,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// A Footnote struct represents a footnote of Markdown
|
||||
// (PHP Markdown Extra) text.
|
||||
type Footnote struct {
|
||||
ast.BaseBlock
|
||||
Ref []byte
|
||||
Index int
|
||||
Name []byte
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *Footnote) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Index"] = strconv.Itoa(n.Index)
|
||||
m["Ref"] = string(n.Ref)
|
||||
m["Name"] = string(n.Name)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindFootnote is a NodeKind of the Footnote node.
|
||||
var KindFootnote = ast.NewNodeKind("GiteaFootnote")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *Footnote) Kind() ast.NodeKind {
|
||||
return KindFootnote
|
||||
}
|
||||
|
||||
// NewFootnote returns a new Footnote node.
|
||||
func NewFootnote(ref []byte) *Footnote {
|
||||
return &Footnote{
|
||||
Ref: ref,
|
||||
Index: -1,
|
||||
Name: ref,
|
||||
}
|
||||
}
|
||||
|
||||
// A FootnoteList struct represents footnotes of Markdown
|
||||
// (PHP Markdown Extra) text.
|
||||
type FootnoteList struct {
|
||||
ast.BaseBlock
|
||||
Count int
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *FootnoteList) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Count"] = strconv.Itoa(n.Count)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindFootnoteList is a NodeKind of the FootnoteList node.
|
||||
var KindFootnoteList = ast.NewNodeKind("GiteaFootnoteList")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *FootnoteList) Kind() ast.NodeKind {
|
||||
return KindFootnoteList
|
||||
}
|
||||
|
||||
// NewFootnoteList returns a new FootnoteList node.
|
||||
func NewFootnoteList() *FootnoteList {
|
||||
return &FootnoteList{
|
||||
Count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
var footnoteListKey = parser.NewContextKey()
|
||||
|
||||
type footnoteBlockParser struct{}
|
||||
|
||||
var defaultFootnoteBlockParser = &footnoteBlockParser{}
|
||||
|
||||
// NewFootnoteBlockParser returns a new parser.BlockParser that can parse
|
||||
// footnotes of the Markdown(PHP Markdown Extra) text.
|
||||
func NewFootnoteBlockParser() parser.BlockParser {
|
||||
return defaultFootnoteBlockParser
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) Trigger() []byte {
|
||||
return []byte{'['}
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
||||
line, segment := reader.PeekLine()
|
||||
pos := pc.BlockOffset()
|
||||
if pos < 0 || line[pos] != '[' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
pos++
|
||||
if pos > len(line)-1 || line[pos] != '^' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
open := pos + 1
|
||||
closure := util.FindClosure(line[pos+1:], '[', ']', false, false) //nolint:staticcheck // deprecated function
|
||||
closes := pos + 1 + closure
|
||||
next := closes + 1
|
||||
if closure > -1 {
|
||||
if next >= len(line) || line[next] != ':' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
} else {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
padding := segment.Padding
|
||||
label := reader.Value(text.NewSegment(segment.Start+open-padding, segment.Start+closes-padding))
|
||||
if util.IsBlank(label) {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
item := NewFootnote(label)
|
||||
|
||||
pos = next + 1 - padding
|
||||
if pos >= len(line) {
|
||||
reader.Advance(pos)
|
||||
return item, parser.NoChildren
|
||||
}
|
||||
reader.AdvanceAndSetPadding(pos, padding)
|
||||
return item, parser.HasChildren
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
||||
line, _ := reader.PeekLine()
|
||||
if util.IsBlank(line) {
|
||||
return parser.Continue | parser.HasChildren
|
||||
}
|
||||
childpos, padding := util.IndentPosition(line, reader.LineOffset(), 4)
|
||||
if childpos < 0 {
|
||||
return parser.Close
|
||||
}
|
||||
reader.AdvanceAndSetPadding(childpos, padding)
|
||||
return parser.Continue | parser.HasChildren
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
} else {
|
||||
list = NewFootnoteList()
|
||||
pc.Set(footnoteListKey, list)
|
||||
node.Parent().InsertBefore(node.Parent(), node, list)
|
||||
}
|
||||
node.Parent().RemoveChild(node.Parent(), node)
|
||||
list.AppendChild(list, node)
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) CanInterruptParagraph() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) CanAcceptIndentedLine() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type footnoteParser struct{}
|
||||
|
||||
var defaultFootnoteParser = &footnoteParser{}
|
||||
|
||||
// NewFootnoteParser returns a new parser.InlineParser that can parse
|
||||
// footnote links of the Markdown(PHP Markdown Extra) text.
|
||||
func NewFootnoteParser() parser.InlineParser {
|
||||
return defaultFootnoteParser
|
||||
}
|
||||
|
||||
func (s *footnoteParser) Trigger() []byte {
|
||||
// footnote syntax probably conflict with the image syntax.
|
||||
// So we need trigger this parser with '!'.
|
||||
return []byte{'!', '['}
|
||||
}
|
||||
|
||||
func (s *footnoteParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
line, segment := block.PeekLine()
|
||||
pos := 1
|
||||
if len(line) > 0 && line[0] == '!' {
|
||||
pos++
|
||||
}
|
||||
if pos >= len(line) || line[pos] != '^' {
|
||||
return nil
|
||||
}
|
||||
pos++
|
||||
if pos >= len(line) {
|
||||
return nil
|
||||
}
|
||||
open := pos
|
||||
closure := util.FindClosure(line[pos:], '[', ']', false, false) //nolint:staticcheck // deprecated function
|
||||
if closure < 0 {
|
||||
return nil
|
||||
}
|
||||
closes := pos + closure
|
||||
value := block.Value(text.NewSegment(segment.Start+open, segment.Start+closes))
|
||||
block.Advance(closes + 1)
|
||||
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
}
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
index := 0
|
||||
name := []byte{}
|
||||
for def := list.FirstChild(); def != nil; def = def.NextSibling() {
|
||||
d := def.(*Footnote)
|
||||
if bytes.Equal(d.Ref, value) {
|
||||
if d.Index < 0 {
|
||||
list.Count++
|
||||
d.Index = list.Count
|
||||
val := CleanValue(d.Name)
|
||||
if len(val) == 0 {
|
||||
val = []byte(strconv.Itoa(d.Index))
|
||||
}
|
||||
d.Name = pc.IDs().Generate(val, KindFootnote)
|
||||
}
|
||||
index = d.Index
|
||||
name = d.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewFootnoteLink(index, name)
|
||||
}
|
||||
|
||||
type footnoteASTTransformer struct{}
|
||||
|
||||
var defaultFootnoteASTTransformer = &footnoteASTTransformer{}
|
||||
|
||||
// NewFootnoteASTTransformer returns a new parser.ASTTransformer that
|
||||
// insert a footnote list to the last of the document.
|
||||
func NewFootnoteASTTransformer() parser.ASTTransformer {
|
||||
return defaultFootnoteASTTransformer
|
||||
}
|
||||
|
||||
func (a *footnoteASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
pc.Set(footnoteListKey, nil)
|
||||
for footnote := list.FirstChild(); footnote != nil; {
|
||||
container := footnote
|
||||
next := footnote.NextSibling()
|
||||
if fc := container.LastChild(); fc != nil && ast.IsParagraph(fc) {
|
||||
container = fc
|
||||
}
|
||||
footnoteNode := footnote.(*Footnote)
|
||||
index := footnoteNode.Index
|
||||
name := footnoteNode.Name
|
||||
if index < 0 {
|
||||
list.RemoveChild(list, footnote)
|
||||
} else {
|
||||
container.AppendChild(container, NewFootnoteBackLink(index, name))
|
||||
}
|
||||
footnote = next
|
||||
}
|
||||
list.SortChildren(func(n1, n2 ast.Node) int {
|
||||
if n1.(*Footnote).Index < n2.(*Footnote).Index {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
if list.Count <= 0 {
|
||||
list.Parent().RemoveChild(list.Parent(), list)
|
||||
return
|
||||
}
|
||||
|
||||
node.AppendChild(node, list)
|
||||
}
|
||||
|
||||
// FootnoteHTMLRenderer is a renderer.NodeRenderer implementation that
|
||||
// renders FootnoteLink nodes.
|
||||
type FootnoteHTMLRenderer struct {
|
||||
html.Config
|
||||
}
|
||||
|
||||
// NewFootnoteHTMLRenderer returns a new FootnoteHTMLRenderer.
|
||||
func NewFootnoteHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &FootnoteHTMLRenderer{
|
||||
Config: html.NewConfig(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
||||
func (r *FootnoteHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(KindFootnoteLink, r.renderFootnoteLink)
|
||||
reg.Register(KindFootnoteBackLink, r.renderFootnoteBackLink)
|
||||
reg.Register(KindFootnote, r.renderFootnote)
|
||||
reg.Register(KindFootnoteList, r.renderFootnoteList)
|
||||
}
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*FootnoteLink)
|
||||
is := strconv.Itoa(n.Index)
|
||||
_, _ = w.WriteString(`<sup id="fnref:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
_, _ = w.WriteString(`"><a href="#fn:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`) // FIXME: here and below, need to keep the classes
|
||||
_, _ = w.WriteString(is)
|
||||
_, _ = w.WriteString(` </a></sup>`) // the style doesn't work at the moment, so add a space to separate the names
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*FootnoteBackLink)
|
||||
_, _ = w.WriteString(` <a href="#fnref:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
_, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
|
||||
_, _ = w.WriteString("↩︎")
|
||||
_, _ = w.WriteString(`</a>`)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*Footnote)
|
||||
if entering {
|
||||
_, _ = w.WriteString(`<li id="fn:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
_, _ = w.WriteString(`" role="doc-endnote"`)
|
||||
if node.Attributes() != nil {
|
||||
html.RenderAttributes(w, node, html.ListItemAttributeFilter)
|
||||
}
|
||||
_, _ = w.WriteString(">\n")
|
||||
} else {
|
||||
_, _ = w.WriteString("</li>\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnoteList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
tag := "div"
|
||||
if entering {
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.WriteString(tag)
|
||||
_, _ = w.WriteString(` class="footnotes" role="doc-endnotes"`)
|
||||
if node.Attributes() != nil {
|
||||
html.RenderAttributes(w, node, html.GlobalAttributeFilter)
|
||||
}
|
||||
_ = w.WriteByte('>')
|
||||
if r.Config.XHTML {
|
||||
_, _ = w.WriteString("\n<hr />\n")
|
||||
} else {
|
||||
_, _ = w.WriteString("\n<hr>\n")
|
||||
}
|
||||
_, _ = w.WriteString("<ol>\n")
|
||||
} else {
|
||||
_, _ = w.WriteString("</ol>\n")
|
||||
_, _ = w.WriteString("</")
|
||||
_, _ = w.WriteString(tag)
|
||||
_, _ = w.WriteString(">\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
type footnoteExtension struct{}
|
||||
|
||||
// FootnoteExtension represents the Gitea Footnote
|
||||
var FootnoteExtension = &footnoteExtension{}
|
||||
|
||||
// Extend extends the markdown converter with the Gitea Footnote parser
|
||||
func (e *footnoteExtension) Extend(m goldmark.Markdown) {
|
||||
m.Parser().AddOptions(
|
||||
parser.WithBlockParsers(
|
||||
util.Prioritized(NewFootnoteBlockParser(), 999),
|
||||
),
|
||||
parser.WithInlineParsers(
|
||||
util.Prioritized(NewFootnoteParser(), 101),
|
||||
),
|
||||
parser.WithASTTransformers(
|
||||
util.Prioritized(NewFootnoteASTTransformer(), 999),
|
||||
),
|
||||
)
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(NewFootnoteHTMLRenderer(), 500),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCleanValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
param string
|
||||
expect string
|
||||
}{
|
||||
// Github behavior test cases
|
||||
{"", ""},
|
||||
{"test(0)", "test0"},
|
||||
{"test!1", "test1"},
|
||||
{"test:2", "test2"},
|
||||
{"test*3", "test3"},
|
||||
{"test!4", "test4"},
|
||||
{"test:5", "test5"},
|
||||
{"test*6", "test6"},
|
||||
{"test:6 a", "test6-a"},
|
||||
{"test:6 !b", "test6-b"},
|
||||
{"test:ad # df", "testad--df"},
|
||||
{"test:ad #23 df 2*/*", "testad-23-df-2"},
|
||||
{"test:ad 23 df 2*/*", "testad-23-df-2"},
|
||||
{"test:ad # 23 df 2*/*", "testad--23-df-2"},
|
||||
{"Anchors in Markdown", "anchors-in-markdown"},
|
||||
{"a_b_c", "a_b_c"},
|
||||
{"a-b-c", "a-b-c"},
|
||||
{"a-b-c----", "a-b-c----"},
|
||||
{"test:6a", "test6a"},
|
||||
{"test:a6", "testa6"},
|
||||
{"tes a a a a", "tes-a-a---a--a"},
|
||||
{" tes a a a a ", "tes-a-a---a--a"},
|
||||
{"Header with \"double quotes\"", "header-with-double-quotes"},
|
||||
{"Placeholder to force scrolling on link's click", "placeholder-to-force-scrolling-on-links-click"},
|
||||
{"tes()", "tes"},
|
||||
{"tes(0)", "tes0"},
|
||||
{"tes{0}", "tes0"},
|
||||
{"tes[0]", "tes0"},
|
||||
{"test【0】", "test0"},
|
||||
{"tes…@a", "tesa"},
|
||||
{"tes¥& a", "tes-a"},
|
||||
{"tes= a", "tes-a"},
|
||||
{"tes|a", "tesa"},
|
||||
{"tes\\a", "tesa"},
|
||||
{"tes/a", "tesa"},
|
||||
{"a啊啊b", "a啊啊b"},
|
||||
{"c🤔️🤔️d", "cd"},
|
||||
{"a⚡a", "aa"},
|
||||
{"e.~f", "ef"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, []byte(test.expect), CleanValue([]byte(test.param)), test.param)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2019 Yusuke Inuzuka
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Most of this file is a subtly changed version of github.com/yuin/goldmark/extension/linkify.go
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
type GlobalVarsType struct {
|
||||
wwwURLRegexp *regexp.Regexp
|
||||
LinkRegex *regexp.Regexp // fast matching a URL link, no any extra validation.
|
||||
}
|
||||
|
||||
var GlobalVars = sync.OnceValue(func() *GlobalVarsType {
|
||||
v := &GlobalVarsType{}
|
||||
v.wwwURLRegexp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`)
|
||||
v.LinkRegex, _ = xurls.StrictMatchingScheme("https?://")
|
||||
return v
|
||||
})
|
||||
|
||||
type linkifyParser struct{}
|
||||
|
||||
var defaultLinkifyParser = &linkifyParser{}
|
||||
|
||||
// NewLinkifyParser return a new InlineParser can parse
|
||||
// text that seems like a URL.
|
||||
func NewLinkifyParser() parser.InlineParser {
|
||||
return defaultLinkifyParser
|
||||
}
|
||||
|
||||
func (s *linkifyParser) Trigger() []byte {
|
||||
// ' ' indicates any white spaces and a line head
|
||||
return []byte{' ', '*', '_', '~', '('}
|
||||
}
|
||||
|
||||
var (
|
||||
protoHTTP = []byte("http:")
|
||||
protoHTTPS = []byte("https:")
|
||||
protoFTP = []byte("ftp:")
|
||||
domainWWW = []byte("www.")
|
||||
)
|
||||
|
||||
func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
if pc.IsInLinkLabel() {
|
||||
return nil
|
||||
}
|
||||
line, segment := block.PeekLine()
|
||||
consumes := 0
|
||||
start := segment.Start
|
||||
c := line[0]
|
||||
// advance if current position is not a line head.
|
||||
if c == ' ' || c == '*' || c == '_' || c == '~' || c == '(' {
|
||||
consumes++
|
||||
start++
|
||||
line = line[1:]
|
||||
}
|
||||
|
||||
var m []int
|
||||
var protocol []byte
|
||||
typ := ast.AutoLinkURL
|
||||
if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) {
|
||||
m = GlobalVars().LinkRegex.FindSubmatchIndex(line)
|
||||
}
|
||||
if m == nil && bytes.HasPrefix(line, domainWWW) {
|
||||
m = GlobalVars().wwwURLRegexp.FindSubmatchIndex(line)
|
||||
protocol = []byte("http")
|
||||
}
|
||||
if m != nil {
|
||||
lastChar := line[m[1]-1]
|
||||
if lastChar == '.' {
|
||||
m[1]--
|
||||
} else if lastChar == ')' {
|
||||
closing := 0
|
||||
for i := m[1] - 1; i >= m[0]; i-- {
|
||||
switch line[i] {
|
||||
case ')':
|
||||
closing++
|
||||
case '(':
|
||||
closing--
|
||||
}
|
||||
}
|
||||
if closing > 0 {
|
||||
m[1] -= closing
|
||||
}
|
||||
} else if lastChar == ';' {
|
||||
// exclude HTML entity reference, e.g.: exclude " " from "http://example.com?foo=1 "
|
||||
i := m[1] - 2
|
||||
for ; i >= m[0]; i-- {
|
||||
if util.IsAlphaNumeric(line[i]) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if i != m[1]-2 {
|
||||
if line[i] == '&' {
|
||||
m[1] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if m == nil {
|
||||
if len(line) > 0 && util.IsPunct(line[0]) {
|
||||
return nil
|
||||
}
|
||||
typ = ast.AutoLinkEmail
|
||||
stop := util.FindEmailIndex(line)
|
||||
if stop < 0 {
|
||||
return nil
|
||||
}
|
||||
at := bytes.IndexByte(line, '@')
|
||||
m = []int{0, stop, at, stop - 1}
|
||||
if bytes.IndexByte(line[m[2]:m[3]], '.') < 0 {
|
||||
return nil
|
||||
}
|
||||
lastChar := line[m[1]-1]
|
||||
if lastChar == '.' {
|
||||
m[1]--
|
||||
}
|
||||
if m[1] < len(line) {
|
||||
nextChar := line[m[1]]
|
||||
if nextChar == '-' || nextChar == '_' {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if consumes != 0 {
|
||||
s := segment.WithStop(segment.Start + 1)
|
||||
ast.MergeOrAppendTextSegment(parent, s)
|
||||
}
|
||||
consumes += m[1]
|
||||
block.Advance(consumes)
|
||||
n := ast.NewTextSegment(text.NewSegment(start, start+m[1]))
|
||||
link := ast.NewAutoLink(typ, n)
|
||||
link.Protocol = protocol
|
||||
return link
|
||||
}
|
||||
|
||||
func (s *linkifyParser) CloseBlock(parent ast.Node, pc parser.Context) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
type linkify struct{}
|
||||
|
||||
// Linkify is an extension that allow you to parse text that seems like a URL.
|
||||
var Linkify = &linkify{}
|
||||
|
||||
func (e *linkify) Extend(m goldmark.Markdown) {
|
||||
m.Parser().AddOptions(
|
||||
parser.WithInlineParsers(
|
||||
util.Prioritized(NewLinkifyParser(), 999),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
trend "github.com/buildkite/terminal-to-html/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(Renderer{})
|
||||
}
|
||||
|
||||
type Renderer struct{}
|
||||
|
||||
var _ markup.RendererContentDetector = (*Renderer)(nil)
|
||||
|
||||
func (Renderer) Name() string {
|
||||
return "console"
|
||||
}
|
||||
|
||||
func (Renderer) FileNamePatterns() []string {
|
||||
return []string{"*.sh-session"}
|
||||
}
|
||||
|
||||
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{
|
||||
{Element: "span", AllowAttr: "class", Regexp: `^term-((fg[ix]?|bg)\d+|container)$`},
|
||||
}
|
||||
}
|
||||
|
||||
func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool {
|
||||
if !sniffedType.IsTextPlain() {
|
||||
return false
|
||||
}
|
||||
|
||||
s := util.UnsafeBytesToString(prefetchBuf)
|
||||
rs := []rune(s)
|
||||
cnt := 0
|
||||
firstErrPos := -1
|
||||
isCtrlSep := func(p int) bool {
|
||||
return p < len(rs) && (rs[p] == ';' || rs[p] == 'm')
|
||||
}
|
||||
for i, c := range rs {
|
||||
if c == 0 {
|
||||
return false
|
||||
}
|
||||
if c == '\x1b' {
|
||||
match := i+1 < len(rs) && rs[i+1] == '['
|
||||
if match && (isCtrlSep(i+2) || isCtrlSep(i+3) || isCtrlSep(i+4) || isCtrlSep(i+5)) {
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
if c == utf8.RuneError && firstErrPos == -1 {
|
||||
firstErrPos = i
|
||||
}
|
||||
}
|
||||
if firstErrPos != -1 && firstErrPos != len(rs)-1 {
|
||||
return false
|
||||
}
|
||||
return cnt >= 2 // only render it as console output if there are at least two escape sequences
|
||||
}
|
||||
|
||||
// Render renders terminal colors to HTML with all specific handling stuff.
|
||||
func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
buf, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf = []byte(trend.Render(buf))
|
||||
buf = bytes.ReplaceAll(buf, []byte("\n"), []byte(`<br>`))
|
||||
_, err = output.Write(buf)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderConsole(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok", `<span class="term-fg37 term-bg40">npm</span> <span class="term-fg32">info</span> <span class="term-fg35">it worked if it ends with</span> ok`},
|
||||
{"\x1b[1;2m \x1b[123m 啊", `<span class="term-fg2"> 啊</span>`},
|
||||
{"\x1b[1;2m \x1b[123m \xef", `<span class="term-fg2"> �</span>`},
|
||||
{"\x1b[1;2m \x1b[123m \xef \xef", ``},
|
||||
{"\x1b[12", ``},
|
||||
{"\x1b[1", ``},
|
||||
{"\x1b[FOO\x1b[", ``},
|
||||
{"\x1b[mFOO\x1b[m", `FOO`},
|
||||
}
|
||||
|
||||
var render Renderer
|
||||
for i, c := range cases {
|
||||
var buf strings.Builder
|
||||
st := typesniffer.DetectContentType([]byte(c.input))
|
||||
canRender := render.CanRender("test", st, []byte(c.input))
|
||||
if c.expected == "" {
|
||||
assert.False(t, canRender, "case %d: expected not to render", i)
|
||||
continue
|
||||
}
|
||||
|
||||
assert.True(t, canRender)
|
||||
err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(c.input), &buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c.expected, buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"html"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/modules/csv"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(Renderer{})
|
||||
}
|
||||
|
||||
type Renderer struct{}
|
||||
|
||||
func (Renderer) Name() string {
|
||||
return "csv"
|
||||
}
|
||||
|
||||
func (Renderer) FileNamePatterns() []string {
|
||||
return []string{"*.csv", "*.tsv"}
|
||||
}
|
||||
|
||||
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{
|
||||
{Element: "table", AllowAttr: "class", Regexp: `^data-table$`},
|
||||
{Element: "th", AllowAttr: "class", Regexp: `^line-num$`},
|
||||
{Element: "td", AllowAttr: "class", Regexp: `^line-num$`},
|
||||
}
|
||||
}
|
||||
|
||||
func writeField(w io.Writer, element, class, field string) error {
|
||||
if _, err := io.WriteString(w, "<"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, element); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(class) > 0 {
|
||||
if _, err := io.WriteString(w, ` class="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, class); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, `"`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := io.WriteString(w, ">"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, html.EscapeString(field)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, "</"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, element); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(w, ">")
|
||||
return err
|
||||
}
|
||||
|
||||
// Render implements markup.Renderer
|
||||
func (r Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
tmpBlock := bufio.NewWriter(output)
|
||||
maxSize := setting.UI.CSV.MaxFileSize
|
||||
maxRows := setting.UI.CSV.MaxRows
|
||||
|
||||
if maxSize != 0 {
|
||||
input = io.LimitReader(input, maxSize+1)
|
||||
}
|
||||
|
||||
rd, err := csv.CreateReaderAndDetermineDelimiter(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmpBlock.WriteString(`<table class="data-table">`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := 0
|
||||
for {
|
||||
fields, err := rd.Read()
|
||||
if err == io.EOF || (row >= maxRows && maxRows != 0) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := tmpBlock.WriteString("<tr>"); err != nil {
|
||||
return err
|
||||
}
|
||||
element := "td"
|
||||
if row == 0 {
|
||||
element = "th"
|
||||
}
|
||||
if err := writeField(tmpBlock, element, "line-num", strconv.Itoa(row+1)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, field := range fields {
|
||||
if err := writeField(tmpBlock, element, "", field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tmpBlock.WriteString("</tr>"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row++
|
||||
}
|
||||
|
||||
if _, err = tmpBlock.WriteString("</table>"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if maxRows or maxSize is reached, and if true, warn.
|
||||
if (row >= maxRows && maxRows != 0) || (rd.InputOffset() >= maxSize && maxSize != 0) {
|
||||
warn := `<table class="data-table"><tr><td>`
|
||||
rawLink := ` <a href="` + ctx.RenderHelper.ResolveLink(util.PathEscapeSegments(ctx.RenderOptions.RelativePath), markup.LinkTypeRaw) + `">`
|
||||
|
||||
// Try to get the user translation
|
||||
if locale, ok := ctx.Value(translation.ContextKey).(translation.Locale); ok {
|
||||
warn += locale.TrString("repo.file_too_large")
|
||||
rawLink += locale.TrString("repo.file_view_raw")
|
||||
} else {
|
||||
warn += "The file is too large to be shown."
|
||||
rawLink += "View Raw"
|
||||
}
|
||||
|
||||
warn += rawLink + `</a></td></tr></table>`
|
||||
|
||||
// Write the HTML string to the output
|
||||
if _, err := tmpBlock.WriteString(warn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tmpBlock.Flush()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderCSV(t *testing.T) {
|
||||
var render Renderer
|
||||
kases := map[string]string{
|
||||
"a": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>a</th></tr></table>",
|
||||
"1,2": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>1</th><th>2</th></tr></table>",
|
||||
"1;2\n3;4": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th>1</th><th>2</th></tr><tr><td class=\"line-num\">2</td><td>3</td><td>4</td></tr></table>",
|
||||
"<br/>": "<table class=\"data-table\"><tr><th class=\"line-num\">1</th><th><br/></th></tr></table>",
|
||||
}
|
||||
|
||||
for k, v := range kases {
|
||||
var buf strings.Builder
|
||||
err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(k), &buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, v, buf.String())
|
||||
}
|
||||
}
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
// RegisterRenderers registers all supported third part renderers according settings
|
||||
func RegisterRenderers() {
|
||||
markup.RegisterRenderer(&frontendRenderer{
|
||||
name: "openapi-swagger",
|
||||
patterns: []string{
|
||||
"openapi.yaml",
|
||||
"openapi.yml",
|
||||
"openapi.json",
|
||||
"swagger.yaml",
|
||||
"swagger.yml",
|
||||
"swagger.json",
|
||||
},
|
||||
})
|
||||
|
||||
markup.RegisterRenderer(&frontendRenderer{
|
||||
name: "viewer-3d",
|
||||
patterns: []string{
|
||||
// It needs more logic to make it overall right (render a text 3D model automatically):
|
||||
// we need to distinguish the ambiguous filename extensions.
|
||||
// For example: "*.amf, *.obj, *.off, *.step" might be or not be a 3D model file.
|
||||
// So when it is a text file, we can't assume that "we only render it by 3D plugin",
|
||||
// otherwise the end users would be impossible to view its real content when the file is not a 3D model.
|
||||
"*.3dm", "*.3ds", "*.3mf", "*.amf", "*.bim", "*.brep",
|
||||
"*.dae", "*.fbx", "*.fcstd", "*.glb", "*.gltf",
|
||||
"*.ifc", "*.igs", "*.iges", "*.stp", "*.step",
|
||||
"*.stl", "*.obj", "*.off", "*.ply", "*.wrl",
|
||||
},
|
||||
})
|
||||
|
||||
for _, renderer := range setting.ExternalMarkupRenderers {
|
||||
markup.RegisterRenderer(&Renderer{renderer})
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for external tools
|
||||
type Renderer struct {
|
||||
*setting.MarkupRenderer
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*Renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*Renderer)(nil)
|
||||
)
|
||||
|
||||
func (p *Renderer) Name() string {
|
||||
return p.MarkupName
|
||||
}
|
||||
|
||||
func (p *Renderer) NeedPostProcess() bool {
|
||||
return p.MarkupRenderer.NeedPostProcess
|
||||
}
|
||||
|
||||
func (p *Renderer) FileNamePatterns() []string {
|
||||
return p.FilePatterns
|
||||
}
|
||||
|
||||
func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return p.MarkupSanitizerRules
|
||||
}
|
||||
|
||||
func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
|
||||
ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
|
||||
ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe
|
||||
ret.ContentSandbox = p.RenderContentSandbox
|
||||
return ret
|
||||
}
|
||||
|
||||
func envMark(envName string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "%" + envName + "%"
|
||||
}
|
||||
return "$" + envName
|
||||
}
|
||||
|
||||
// Render renders the data of the document to HTML via the external tool.
|
||||
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||
command := strings.NewReplacer(
|
||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
||||
).Replace(p.Command)
|
||||
commands, err := shellquote.Split(command)
|
||||
if err != nil || len(commands) == 0 {
|
||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args := commands[1:]
|
||||
|
||||
if p.IsInputFile {
|
||||
// write to temp file
|
||||
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
_, err = io.Copy(f, input)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
}
|
||||
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||
"GITEA_PREFIX_RAW="+baseLinkRaw,
|
||||
)
|
||||
if !p.IsInputFile {
|
||||
cmd.Stdin = input
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = output
|
||||
cmd.Stderr = &stderr
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Vendored
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type frontendRenderer struct {
|
||||
name string
|
||||
patterns []string
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
)
|
||||
|
||||
func (p *frontendRenderer) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
||||
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
||||
// There are some approaches to make it more accurate, but they are all complicated:
|
||||
// A. Make backend know everything (detect a file is a 3D model or not)
|
||||
// B. Let frontend renders to try render one by one
|
||||
//
|
||||
// If there would be more frontend renders in the future, we need to implement the "frontend" approach:
|
||||
// 1. Make backend or parent window collect the supported extensions of frontend renders (done: backend external render framework)
|
||||
// 2. If the current file matches any extension, start the general iframe embedded render (done: this renderer)
|
||||
// 3. The iframe window calls the frontend renders one by one (done: frontend external render)
|
||||
// 4. Report the render result to parent by postMessage (TODO: when needed)
|
||||
return p.patterns
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
|
||||
ret.SanitizerDisabled = true
|
||||
ret.DisplayInIframe = true
|
||||
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
if ctx.RenderOptions.StandalonePageOptions == nil {
|
||||
opts := p.GetExternalRendererOptions()
|
||||
return markup.RenderIFrame(ctx, &opts, output)
|
||||
}
|
||||
|
||||
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentEncoding, contentString := "text", util.UnsafeBytesToString(content)
|
||||
if !utf8.Valid(content) {
|
||||
contentEncoding = "base64"
|
||||
contentString = base64.StdEncoding.EncodeToString(content)
|
||||
}
|
||||
|
||||
_, err = htmlutil.HTMLPrintf(output,
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- external-render-helper will be injected here by the markup render -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="frontend-render-viewer" data-frontend-renders="%s" data-file-tree-path="%s"></div>
|
||||
<textarea id="frontend-render-data" data-content-encoding="%s" hidden>%s</textarea>
|
||||
<script nonce type="module" src="%s"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
p.name, ctx.RenderOptions.RelativePath,
|
||||
contentEncoding, contentString,
|
||||
public.AssetURI("web_src/js/external-render-frontend.ts"))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
// Issue name styles
|
||||
const (
|
||||
IssueNameStyleNumeric = "numeric"
|
||||
IssueNameStyleAlphanumeric = "alphanumeric"
|
||||
IssueNameStyleRegexp = "regexp"
|
||||
)
|
||||
|
||||
type globalVarsType struct {
|
||||
hashCurrentPattern *regexp.Regexp
|
||||
shortLinkPattern *regexp.Regexp
|
||||
anyHashPattern *regexp.Regexp
|
||||
comparePattern *regexp.Regexp
|
||||
fullURLPattern *regexp.Regexp
|
||||
emailRegex *regexp.Regexp
|
||||
emojiShortCodeRegex *regexp.Regexp
|
||||
issueFullPattern *regexp.Regexp
|
||||
filesChangedFullPattern *regexp.Regexp
|
||||
codePreviewPattern *regexp.Regexp
|
||||
|
||||
tagCleaner *regexp.Regexp
|
||||
nulCleaner *strings.Replacer
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() *globalVarsType {
|
||||
v := &globalVarsType{}
|
||||
// NOTE: All below regex matching do not perform any extra validation.
|
||||
// Thus a link is produced even if the linked entity does not exist.
|
||||
// While fast, this is also incorrect and lead to false positives.
|
||||
// TODO: fix invalid linking issue (update: stale TODO, what issues? maybe no TODO anymore)
|
||||
|
||||
// valid chars in encoded path and parameter: [-+~_%.a-zA-Z0-9/]
|
||||
|
||||
// hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae
|
||||
// Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length
|
||||
// so that abbreviated hash links can be used as well. This matches git and GitHub usability.
|
||||
v.hashCurrentPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-f]{7,64})(?:\s|$|\)|\]|[.,:](\s|$))`)
|
||||
|
||||
// shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax
|
||||
v.shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`)
|
||||
|
||||
// anyHashPattern splits url containing SHA into parts
|
||||
v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})((\.\w+)*)(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`)
|
||||
|
||||
// comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
|
||||
v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
|
||||
|
||||
// fullURLPattern matches full URL like "mailto:...", "https://..." and "ssh+git://..."
|
||||
v.fullURLPattern = regexp.MustCompile(`^[a-z][-+\w]+:`)
|
||||
|
||||
// emailRegex is definitely not perfect with edge cases,
|
||||
// it is still accepted by the CommonMark specification, as well as the HTML5 spec:
|
||||
// http://spec.commonmark.org/0.28/#email-address
|
||||
// https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail)
|
||||
// At the moment, we use stricter rule for rendering purpose: only allow the "name" part starting after the word boundary
|
||||
v.emailRegex = regexp.MustCompile(`\b([-\w.!#$%&'*+/=?^{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)\b`)
|
||||
|
||||
// emojiShortCodeRegex find emoji by alias like :smile:
|
||||
v.emojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
|
||||
|
||||
// example: https://domain/org/repo/pulls/27#hash
|
||||
v.issueFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#](\S+)?)?\b`)
|
||||
|
||||
// example: https://domain/org/repo/pulls/27/files#hash
|
||||
v.filesChangedFullPattern = regexp.MustCompile(`https?://(?:\S+/)[\w_.-]+/[\w_.-]+/pulls/((?:\w{1,10}-)?[1-9][0-9]*)/files([\?|#](\S+)?)?\b`)
|
||||
|
||||
// codePreviewPattern matches "http://domain/.../{owner}/{repo}/src/commit/{commit}/{filepath}#L10-L20"
|
||||
v.codePreviewPattern = regexp.MustCompile(`https?://\S+/([^\s/]+)/([^\s/]+)/src/commit/([0-9a-f]{7,64})(/\S+)#(L\d+(-L\d+)?)`)
|
||||
|
||||
// cleans: "<foo/bar", "<any words/", ("<html", "<head", "<script", "<style", "<?", "<%")
|
||||
v.tagCleaner = regexp.MustCompile(`(?i)<(/?\w+/\w+|/[\w ]+/|/?(html|head|script|style|%|\?)\b)`)
|
||||
v.nulCleaner = strings.NewReplacer("\000", "")
|
||||
return v
|
||||
})
|
||||
|
||||
func IsFullURLString(link string) bool {
|
||||
return globalVars().fullURLPattern.MatchString(link)
|
||||
}
|
||||
|
||||
func IsNonEmptyRelativePath(link string) bool {
|
||||
return link != "" && !IsFullURLString(link) && link[0] != '?' && link[0] != '#'
|
||||
}
|
||||
|
||||
// CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text
|
||||
func CustomLinkURLSchemes(schemes []string) {
|
||||
schemes = append(schemes, "http", "https")
|
||||
withAuth := make([]string, 0, len(schemes))
|
||||
validScheme := regexp.MustCompile(`^[a-z]+$`)
|
||||
for _, s := range schemes {
|
||||
if !validScheme.MatchString(s) {
|
||||
continue
|
||||
}
|
||||
without := slices.Contains(xurls.SchemesNoAuthority, s)
|
||||
if without {
|
||||
s += ":"
|
||||
} else {
|
||||
s += "://"
|
||||
}
|
||||
withAuth = append(withAuth, s)
|
||||
}
|
||||
common.GlobalVars().LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|"))
|
||||
}
|
||||
|
||||
type processor func(ctx *RenderContext, node *html.Node)
|
||||
|
||||
// PostProcessDefault does the final required transformations to the passed raw HTML
|
||||
// data, and ensures its validity. Transformations include: replacing links and
|
||||
// emails with HTML links, parsing shortlinks in the format of [[Link]], like
|
||||
// MediaWiki, linking issues in the format #ID, and mentions in the format
|
||||
// @user, and others.
|
||||
func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) error {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
codePreviewPatternProcessor,
|
||||
fullHashPatternProcessor,
|
||||
shortLinkProcessor,
|
||||
linkProcessor,
|
||||
mentionProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emailAddressProcessor,
|
||||
emojiProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
}
|
||||
return postProcess(ctx, procs, input, output)
|
||||
}
|
||||
|
||||
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor.
|
||||
// FIXME: this function and its family have a very strange design: it takes HTML as input and output, processes the "escaped" content.
|
||||
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (template.HTML, error) {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
fullHashPatternProcessor,
|
||||
linkProcessor,
|
||||
mentionProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emailAddressProcessor,
|
||||
emojiProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
}
|
||||
s, err := postProcessString(ctx, procs, string(content))
|
||||
return template.HTML(s), err
|
||||
}
|
||||
|
||||
var emojiProcessors = []processor{
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}
|
||||
|
||||
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
|
||||
// is entirely a single URL, ignoring leading/trailing whitespace.
|
||||
func isBareURLSubject(content string) bool {
|
||||
s := strings.TrimSpace(html.UnescapeString(content))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
|
||||
return m != nil && m[0] == 0 && m[1] == len(s)
|
||||
}
|
||||
|
||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
fullHashPatternProcessor,
|
||||
mentionProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}
|
||||
// When the whole subject is a bare URL, linkProcessor would turn it into
|
||||
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
|
||||
// the subject visually unclickable. Match GitHub: render such subjects as
|
||||
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||
// naturally breaks on that span, same as on GitHub).
|
||||
if !isBareURLSubject(content) {
|
||||
procs = append(procs, linkProcessor)
|
||||
}
|
||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||
node.Type = html.ElementNode
|
||||
node.Data = "a"
|
||||
node.DataAtom = atom.A
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}}
|
||||
node.FirstChild, node.LastChild = ch, ch
|
||||
})
|
||||
return postProcessString(ctx, procs, content)
|
||||
}
|
||||
|
||||
// PostProcessIssueTitle to process title on individual issue/pull page
|
||||
func PostProcessIssueTitle(ctx *RenderContext, title string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}, title)
|
||||
}
|
||||
|
||||
// PostProcessDescriptionHTML will use similar logic as PostProcess, but will
|
||||
// use a single special linkProcessor.
|
||||
func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
descriptionLinkProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}, content)
|
||||
}
|
||||
|
||||
// PostProcessEmoji for when we want to just process emoji and shortcodes
|
||||
// in various places it isn't already run through the normal markdown processor
|
||||
func PostProcessEmoji(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, emojiProcessors, content)
|
||||
}
|
||||
|
||||
func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) {
|
||||
locale, ok := ctx.Value(translation.ContextKey).(translation.Locale)
|
||||
if !ok {
|
||||
locale = translation.NewLocale("")
|
||||
}
|
||||
_, _ = htmlutil.HTMLPrintTag(out, "details", nodeDetailsAttrs)
|
||||
_, _ = htmlutil.HTMLPrintf(out, "<summary>%s</summary>\n", locale.TrString("toc"))
|
||||
|
||||
baseLevel := 6
|
||||
for _, header := range ctx.TocHeadingItems {
|
||||
if header.HeadingLevel < baseLevel {
|
||||
baseLevel = header.HeadingLevel
|
||||
}
|
||||
}
|
||||
|
||||
currentLevel := baseLevel
|
||||
indent := []byte{' ', ' '}
|
||||
_, _ = htmlutil.HTMLPrint(out, "<ul>\n")
|
||||
for _, header := range ctx.TocHeadingItems {
|
||||
for currentLevel < header.HeadingLevel {
|
||||
_, _ = out.Write(indent)
|
||||
_, _ = htmlutil.HTMLPrint(out, "<ul>\n")
|
||||
indent = append(indent, ' ', ' ')
|
||||
currentLevel++
|
||||
}
|
||||
for currentLevel > header.HeadingLevel {
|
||||
indent = indent[:len(indent)-2]
|
||||
_, _ = out.Write(indent)
|
||||
_, _ = htmlutil.HTMLPrint(out, "</ul>\n")
|
||||
currentLevel--
|
||||
}
|
||||
_, _ = out.Write(indent)
|
||||
_, _ = htmlutil.HTMLPrintf(out, "<li><a href=\"#%s\">%s</a></li>\n", header.AnchorID, header.InnerText)
|
||||
}
|
||||
for currentLevel > baseLevel {
|
||||
indent = indent[:len(indent)-2]
|
||||
_, _ = out.Write(indent)
|
||||
_, _ = htmlutil.HTMLPrint(out, "</ul>\n")
|
||||
currentLevel--
|
||||
}
|
||||
_, _ = htmlutil.HTMLPrint(out, "</ul>\n</details>\n")
|
||||
}
|
||||
|
||||
func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error {
|
||||
if !ctx.usedByRender && ctx.RenderHelper != nil {
|
||||
defer ctx.RenderHelper.CleanUp()
|
||||
}
|
||||
// FIXME: don't read all content to memory
|
||||
rawHTML, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse the HTML
|
||||
node, err := html.Parse(io.MultiReader(
|
||||
// prepend "<html><body>"
|
||||
strings.NewReader("<html><body>"),
|
||||
// strip out NULLs (they're always invalid), and escape known tags
|
||||
bytes.NewReader(globalVars().tagCleaner.ReplaceAll([]byte(globalVars().nulCleaner.Replace(string(rawHTML))), []byte("<$1"))),
|
||||
// close the tags
|
||||
strings.NewReader("</body></html>"),
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("markup.postProcess: invalid HTML: %w", err)
|
||||
}
|
||||
|
||||
if node.Type == html.DocumentNode {
|
||||
node = node.FirstChild
|
||||
}
|
||||
|
||||
visitNode(ctx, procs, node)
|
||||
|
||||
newNodes := make([]*html.Node, 0, 5)
|
||||
|
||||
if node.Data == "html" {
|
||||
node = node.FirstChild
|
||||
for node != nil && node.Data != "body" {
|
||||
node = node.NextSibling
|
||||
}
|
||||
}
|
||||
if node != nil {
|
||||
if node.Data == "body" {
|
||||
child := node.FirstChild
|
||||
for child != nil {
|
||||
newNodes = append(newNodes, child)
|
||||
child = child.NextSibling
|
||||
}
|
||||
} else {
|
||||
newNodes = append(newNodes, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Render everything to buf.
|
||||
if ctx.TocShowInSection == TocShowInMain && len(ctx.TocHeadingItems) > 0 {
|
||||
RenderTocHeadingItems(ctx, nil, output)
|
||||
}
|
||||
for _, node := range newNodes {
|
||||
if err := html.Render(output, node); err != nil {
|
||||
return fmt.Errorf("markup.postProcess: html.Render: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmojiNode(node *html.Node) bool {
|
||||
if node.Type == html.ElementNode && node.Data == atom.Span.String() {
|
||||
for _, attr := range node.Attr {
|
||||
if (attr.Key == "class" || attr.Key == "data-attr-class") && strings.Contains(attr.Val, "emoji") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Node {
|
||||
if node.Type == html.TextNode {
|
||||
for _, proc := range procs {
|
||||
proc(ctx, node) // it might add siblings
|
||||
}
|
||||
return node.NextSibling
|
||||
}
|
||||
if node.Type != html.ElementNode {
|
||||
return node.NextSibling
|
||||
}
|
||||
|
||||
processNodeHeadingAndID(ctx, node)
|
||||
processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly
|
||||
|
||||
if isEmojiNode(node) {
|
||||
// TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
|
||||
// if we don't stop it, it will go into the TextNode again and create an infinite recursion
|
||||
return node.NextSibling
|
||||
} else if node.Data == "code" || node.Data == "pre" {
|
||||
return node.NextSibling // ignore code and pre nodes
|
||||
} else if node.Data == "img" {
|
||||
return visitNodeImg(ctx, node)
|
||||
} else if node.Data == "video" {
|
||||
return visitNodeVideo(ctx, node)
|
||||
}
|
||||
|
||||
if node.Data == "a" {
|
||||
processNodeA(ctx, node)
|
||||
// only use emoji processors for the content in the "A" tag,
|
||||
// because the content there is not processable, for example: the content is a commit id or a full URL.
|
||||
procs = emojiProcessors
|
||||
}
|
||||
for n := node.FirstChild; n != nil; {
|
||||
n = visitNode(ctx, procs, n)
|
||||
}
|
||||
return node.NextSibling
|
||||
}
|
||||
|
||||
// createKeyword() renders a highlighted version of an action keyword
|
||||
func createKeyword(ctx *RenderContext, content string) *html.Node {
|
||||
// CSS class for action keywords (e.g. "closes: #1")
|
||||
const keywordClass = "issue-keyword"
|
||||
|
||||
span := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.Span.String(),
|
||||
Attr: []html.Attribute{},
|
||||
}
|
||||
span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", keywordClass))
|
||||
|
||||
text := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
span.AppendChild(text)
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
func createLink(ctx *RenderContext, href, content, class string) *html.Node {
|
||||
a := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.A.String(),
|
||||
Attr: []html.Attribute{{Key: "href", Val: href}},
|
||||
}
|
||||
if !RenderBehaviorForTesting.DisableAdditionalAttributes {
|
||||
a.Attr = append(a.Attr, html.Attribute{Key: "data-markdown-generated-content"})
|
||||
}
|
||||
if class != "" {
|
||||
a.Attr = append(a.Attr, ctx.RenderInternal.NodeSafeAttr("class", class))
|
||||
}
|
||||
|
||||
text := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
|
||||
a.AppendChild(text)
|
||||
return a
|
||||
}
|
||||
|
||||
// replaceContent takes text node, and in its content it replaces a section of
|
||||
// it with the specified newNode.
|
||||
func replaceContent(node *html.Node, i, j int, newNode *html.Node) {
|
||||
replaceContentList(node, i, j, []*html.Node{newNode})
|
||||
}
|
||||
|
||||
// replaceContentList takes text node, and in its content it replaces a section of
|
||||
// it with the specified newNodes. An example to visualize how this can work can
|
||||
// be found here: https://play.golang.org/p/5zP8NnHZ03s
|
||||
func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) {
|
||||
// get the data before and after the match
|
||||
before := node.Data[:i]
|
||||
after := node.Data[j:]
|
||||
|
||||
// Replace in the current node the text, so that it is only what it is
|
||||
// supposed to have.
|
||||
node.Data = before
|
||||
|
||||
// Get the current next sibling, before which we place the replaced data,
|
||||
// and after that we place the new text node.
|
||||
nextSibling := node.NextSibling
|
||||
for _, n := range newNodes {
|
||||
node.Parent.InsertBefore(n, nextSibling)
|
||||
}
|
||||
if after != "" {
|
||||
node.Parent.InsertBefore(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: after,
|
||||
}, nextSibling)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
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 := globalVars().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(ctx, 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 := DefaultRenderHelperFuncs.RenderRepoFileCodePreview(ctx, opts)
|
||||
return m[0], m[1], h, err
|
||||
}
|
||||
|
||||
func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
nodeStop := node.NextSibling
|
||||
for node != nodeStop {
|
||||
if node.Type != html.TextNode {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
urlPosStart, urlPosEnd, renderedCodeBlock, err := renderCodeBlock(ctx, node)
|
||||
if err != nil || renderedCodeBlock == "" {
|
||||
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
|
||||
renderedCodeNode := &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(renderedCodeBlock))}
|
||||
node.Parent.InsertBefore(renderedCodeNode, next)
|
||||
if textAfter != "" {
|
||||
node.Parent.InsertBefore(&html.Node{Type: html.TextNode, Data: textAfter}, next)
|
||||
}
|
||||
node = next
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderCodePreview(t *testing.T) {
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
RenderRepoFileCodePreview: func(ctx context.Context, options markup.RenderCodePreviewOptions) (template.HTML, error) {
|
||||
return "<div>code preview</div>", nil
|
||||
},
|
||||
})
|
||||
test := func(input, expected string) {
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), 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,245 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/references"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
type anyHashPatternResult struct {
|
||||
PosStart int
|
||||
PosEnd int
|
||||
FullURL string
|
||||
CommitID string
|
||||
CommitExt string
|
||||
SubPath string
|
||||
QueryParams string
|
||||
QueryHash string
|
||||
}
|
||||
|
||||
func createCodeLink(href, content, class string) *html.Node {
|
||||
a := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.A.String(),
|
||||
Attr: []html.Attribute{{Key: "href", Val: href}},
|
||||
}
|
||||
|
||||
if class != "" {
|
||||
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
|
||||
}
|
||||
|
||||
text := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
|
||||
code := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.Code.String(),
|
||||
}
|
||||
|
||||
code.AppendChild(text)
|
||||
a.AppendChild(code)
|
||||
return a
|
||||
}
|
||||
|
||||
func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) {
|
||||
m := globalVars().anyHashPattern.FindStringSubmatchIndex(s)
|
||||
if m == nil {
|
||||
return ret, false
|
||||
}
|
||||
|
||||
pos := 0
|
||||
|
||||
ret.PosStart, ret.PosEnd = m[pos], m[pos+1]
|
||||
pos += 2
|
||||
|
||||
ret.FullURL = s[ret.PosStart:ret.PosEnd]
|
||||
if strings.HasSuffix(ret.FullURL, ".") {
|
||||
// if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence.
|
||||
ret.PosEnd--
|
||||
ret.FullURL = ret.FullURL[:len(ret.FullURL)-1]
|
||||
for i := range m {
|
||||
m[i] = min(m[i], ret.PosEnd)
|
||||
}
|
||||
}
|
||||
|
||||
ret.CommitID = s[m[pos]:m[pos+1]]
|
||||
pos += 2
|
||||
|
||||
ret.CommitExt = s[m[pos]:m[pos+1]]
|
||||
pos += 4
|
||||
|
||||
if m[pos] > 0 {
|
||||
ret.SubPath = s[m[pos]:m[pos+1]]
|
||||
}
|
||||
pos += 2
|
||||
|
||||
if m[pos] > 0 {
|
||||
ret.QueryParams = s[m[pos]:m[pos+1]]
|
||||
}
|
||||
pos += 2
|
||||
|
||||
if m[pos] > 0 {
|
||||
ret.QueryHash = s[m[pos]:m[pos+1]][1:]
|
||||
}
|
||||
return ret, true
|
||||
}
|
||||
|
||||
// fullHashPatternProcessor renders SHA containing URLs
|
||||
func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if ctx.RenderOptions.Metas == nil {
|
||||
return
|
||||
}
|
||||
nodeStop := node.NextSibling
|
||||
for node != nodeStop {
|
||||
if node.Type != html.TextNode {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
ret, ok := anyHashPatternExtract(node.Data)
|
||||
if !ok {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
text := base.ShortSha(ret.CommitID)
|
||||
if ret.CommitExt != "" {
|
||||
text += ret.CommitExt
|
||||
}
|
||||
if ret.SubPath != "" {
|
||||
text += ret.SubPath
|
||||
}
|
||||
if ret.QueryHash != "" {
|
||||
text += " (" + ret.QueryHash + ")"
|
||||
}
|
||||
// only turn commit links to the current instance into hash link
|
||||
if !httplib.IsCurrentGiteaSiteURL(ctx, ret.FullURL) {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit"))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if ctx.RenderOptions.Metas == nil {
|
||||
return
|
||||
}
|
||||
nodeStop := node.NextSibling
|
||||
for node != nodeStop {
|
||||
if node.Type != html.TextNode {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
m := globalVars().comparePattern.FindStringSubmatchIndex(node.Data)
|
||||
if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
|
||||
urlFull := node.Data[m[0]:m[1]]
|
||||
text1 := base.ShortSha(node.Data[m[2]:m[3]])
|
||||
textDots := base.ShortSha(node.Data[m[4]:m[5]])
|
||||
text2 := base.ShortSha(node.Data[m[6]:m[7]])
|
||||
|
||||
hash := ""
|
||||
if m[9] > 0 {
|
||||
hash = node.Data[m[8]:m[9]][1:]
|
||||
}
|
||||
|
||||
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]
|
||||
if hash != "" {
|
||||
hash = hash[:len(hash)-1]
|
||||
} else if text2 != "" {
|
||||
text2 = text2[:len(text2)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// only turn compare links to the current instance into hash link
|
||||
if !httplib.IsCurrentGiteaSiteURL(ctx, urlFull) {
|
||||
node = node.NextSibling
|
||||
continue
|
||||
}
|
||||
|
||||
text := text1 + textDots + text2
|
||||
if hash != "" {
|
||||
text += " (" + hash + ")"
|
||||
}
|
||||
replaceContent(node, start, end, createCodeLink(urlFull, text, "compare"))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that
|
||||
// are assumed to be in the same repository.
|
||||
func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if ctx.RenderOptions.Metas == nil || ctx.RenderOptions.Metas["user"] == "" || ctx.RenderOptions.Metas["repo"] == "" || ctx.RenderHelper == nil {
|
||||
return
|
||||
}
|
||||
|
||||
start := 0
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next && start < len(node.Data) {
|
||||
m := globalVars().hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:])
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m[2] += start
|
||||
m[3] += start
|
||||
|
||||
hash := node.Data[m[2]:m[3]]
|
||||
// The regex does not lie, it matches the hash pattern.
|
||||
// However, a regex cannot know if a hash actually exists or not.
|
||||
// We could assume that a SHA1 hash should probably contain alphas AND numerics
|
||||
// but that is not always the case.
|
||||
// Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
|
||||
// as used by git and github for linking and thus we have to do similar.
|
||||
// Because of this, we check to make sure that a matched hash is actually
|
||||
// a commit in the repository before making it a link.
|
||||
if !ctx.RenderHelper.IsCommitIDExisting(hash) {
|
||||
start = m[3]
|
||||
continue
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("/:root/%s/%s/commit/%s", ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"], hash)
|
||||
replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
|
||||
start = 0
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
next := node.NextSibling
|
||||
|
||||
for node != nil && node != next {
|
||||
found, ref := references.FindRenderizableCommitCrossReference(node.Data)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
refText := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
|
||||
linkHref := fmt.Sprintf("/:root/%s/%s/commit/%s", ref.Owner, ref.Name, ref.CommitSha)
|
||||
link := createLink(ctx, linkHref, refText, "commit")
|
||||
|
||||
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// emailAddressProcessor replaces raw email addresses with a mailto: link.
|
||||
func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
m := globalVars().emailRegex.FindStringSubmatchIndex(node.Data)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var nextByte byte
|
||||
if len(node.Data) > m[3] {
|
||||
nextByte = node.Data[m[3]]
|
||||
}
|
||||
if strings.IndexByte(":/", nextByte) != -1 {
|
||||
// for cases: "git@gitea.com:owner/repo.git", "https://git@gitea.com/owner/repo.git"
|
||||
return
|
||||
}
|
||||
mail := node.Data[m[2]:m[3]]
|
||||
replaceContent(node, m[2], m[3], createLink(ctx, "mailto:"+mail, mail, "" /*mailto*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
func createEmoji(ctx *RenderContext, content, name string) *html.Node {
|
||||
span := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.Span.String(),
|
||||
Attr: []html.Attribute{},
|
||||
}
|
||||
span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji"))
|
||||
if name != "" {
|
||||
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name})
|
||||
}
|
||||
|
||||
text := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
|
||||
span.AppendChild(text)
|
||||
return span
|
||||
}
|
||||
|
||||
func createCustomEmoji(ctx *RenderContext, alias string) *html.Node {
|
||||
span := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: atom.Span.String(),
|
||||
Attr: []html.Attribute{},
|
||||
}
|
||||
span.Attr = append(span.Attr, ctx.RenderInternal.NodeSafeAttr("class", "emoji"))
|
||||
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias})
|
||||
|
||||
img := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
DataAtom: atom.Img,
|
||||
Data: "img",
|
||||
Attr: []html.Attribute{},
|
||||
}
|
||||
img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"})
|
||||
img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"})
|
||||
|
||||
span.AppendChild(img)
|
||||
return span
|
||||
}
|
||||
|
||||
// emojiShortCodeProcessor for rendering text like :smile: into emoji
|
||||
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
|
||||
start := 0
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next && start < len(node.Data) {
|
||||
m := globalVars().emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:])
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m[0] += start
|
||||
m[1] += start
|
||||
start = m[1]
|
||||
|
||||
alias := node.Data[m[0]:m[1]]
|
||||
|
||||
var nextChar byte
|
||||
if m[1] < len(node.Data) {
|
||||
nextChar = node.Data[m[1]]
|
||||
}
|
||||
if nextChar == ':' || unicode.IsLetter(rune(nextChar)) || unicode.IsDigit(rune(nextChar)) {
|
||||
continue
|
||||
}
|
||||
|
||||
alias = strings.Trim(alias, ":")
|
||||
converted := emoji.FromAlias(alias)
|
||||
if converted != nil {
|
||||
// standard emoji
|
||||
replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0 // restart searching start since node has changed
|
||||
} else if _, exist := setting.UI.CustomEmojisMap[alias]; exist {
|
||||
// custom reaction
|
||||
replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0 // restart searching start since node has changed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emoji processor to match emoji and add emoji class
|
||||
func emojiProcessor(ctx *RenderContext, node *html.Node) {
|
||||
start := 0
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next && start < len(node.Data) {
|
||||
m := emoji.FindEmojiSubmatchIndex(node.Data[start:])
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m[0] += start
|
||||
m[1] += start
|
||||
|
||||
codepoint := node.Data[m[0]:m[1]]
|
||||
start = m[1]
|
||||
val := emoji.FromCode(codepoint)
|
||||
if val != nil {
|
||||
replaceContent(node, m[0], m[1], createEmoji(ctx, codepoint, val.Description))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
testModule "gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
TestAppURL = "http://localhost:3000/"
|
||||
TestRepoURL = TestAppURL + "test-owner/test-repo/"
|
||||
)
|
||||
|
||||
// externalIssueLink an HTML link to an alphanumeric-style issue
|
||||
func externalIssueLink(baseURL, class, name string) string {
|
||||
return link(strings.TrimSuffix(baseURL, "/")+"/"+name, class, name)
|
||||
}
|
||||
|
||||
// numericLink an HTML to a numeric-style issue
|
||||
func numericIssueLink(baseURL, class string, index int, marker string) string {
|
||||
return link(strings.TrimSuffix(baseURL, "/")+"/"+strconv.Itoa(index), class, fmt.Sprintf("%s%d", marker, index))
|
||||
}
|
||||
|
||||
// link an HTML link
|
||||
func link(href, class, contents string) string {
|
||||
extra := util.Iif(class != "", ` class="`+class+`"`, "")
|
||||
return fmt.Sprintf(`<a href="%s"%s>%s</a>`, href, extra, contents)
|
||||
}
|
||||
|
||||
var numericMetas = map[string]string{
|
||||
"format": "https://someurl.com/{user}/{repo}/{index}",
|
||||
"user": "someUser",
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleNumeric,
|
||||
"markupAllowShortIssuePattern": "true",
|
||||
}
|
||||
|
||||
var alphanumericMetas = map[string]string{
|
||||
"format": "https://someurl.com/{user}/{repo}/{index}",
|
||||
"user": "someUser",
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleAlphanumeric,
|
||||
"markupAllowShortIssuePattern": "true",
|
||||
}
|
||||
|
||||
var regexpMetas = map[string]string{
|
||||
"format": "https://someurl.com/{user}/{repo}/{index}",
|
||||
"user": "someUser",
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleRegexp,
|
||||
}
|
||||
|
||||
// these values should match the TestOrgRepo const above
|
||||
var localMetas = map[string]string{
|
||||
"user": "test-owner",
|
||||
"repo": "test-repo",
|
||||
"markupAllowShortIssuePattern": "true",
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern(t *testing.T) {
|
||||
// numeric: render inputs without valid mentions
|
||||
test := func(s string) {
|
||||
testRenderIssueIndexPattern(t, s, s, NewTestRenderContext())
|
||||
testRenderIssueIndexPattern(t, s, s, NewTestRenderContext(numericMetas))
|
||||
}
|
||||
|
||||
// should not render anything when there are no mentions
|
||||
test("")
|
||||
test("this is a test")
|
||||
test("test 123 123 1234")
|
||||
test("#")
|
||||
test("# # #")
|
||||
test("# 123")
|
||||
test("#abcd")
|
||||
test("test#1234")
|
||||
test("#1234test")
|
||||
test("#abcd")
|
||||
test("test!1234")
|
||||
test("!1234test")
|
||||
test(" test !1234test")
|
||||
test("/home/gitea/#1234")
|
||||
test("/home/gitea/!1234")
|
||||
|
||||
// should not render issue mention without leading space
|
||||
test("test#54321 issue")
|
||||
|
||||
// should not render issue mention without trailing space
|
||||
test("test #54321issue")
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern2(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
|
||||
// numeric: render inputs with valid mentions
|
||||
test := func(s, expectedFmt, marker string, indices ...int) {
|
||||
var path, prefix string
|
||||
isExternal := false
|
||||
if marker == "!" {
|
||||
path = "pulls"
|
||||
prefix = "/someUser/someRepo/pulls/"
|
||||
} else {
|
||||
path = "issues"
|
||||
prefix = "https://someurl.com/someUser/someRepo/"
|
||||
isExternal = true
|
||||
}
|
||||
|
||||
links := make([]any, len(indices))
|
||||
for i, index := range indices {
|
||||
links[i] = numericIssueLink("/test-owner/test-repo/"+path, "ref-issue", index, marker)
|
||||
}
|
||||
expectedNil := fmt.Sprintf(expectedFmt, links...)
|
||||
testRenderIssueIndexPattern(t, s, expectedNil, NewTestRenderContext(TestAppURL, localMetas))
|
||||
|
||||
class := "ref-issue"
|
||||
if isExternal {
|
||||
class += " ref-external-issue"
|
||||
}
|
||||
|
||||
for i, index := range indices {
|
||||
links[i] = numericIssueLink(prefix, class, index, marker)
|
||||
}
|
||||
expectedNum := fmt.Sprintf(expectedFmt, links...)
|
||||
testRenderIssueIndexPattern(t, s, expectedNum, NewTestRenderContext(TestAppURL, numericMetas))
|
||||
}
|
||||
|
||||
// should render freestanding mentions
|
||||
test("#1234 test", "%s test", "#", 1234)
|
||||
test("test #8 issue", "test %s issue", "#", 8)
|
||||
test("!1234 test", "%s test", "!", 1234)
|
||||
test("test !8 issue", "test %s issue", "!", 8)
|
||||
test("test issue #1234", "test issue %s", "#", 1234)
|
||||
test("fixes issue #1234.", "fixes issue %s.", "#", 1234)
|
||||
|
||||
// should render mentions in parentheses / brackets
|
||||
test("(#54321 issue)", "(%s issue)", "#", 54321)
|
||||
test("[#54321 issue]", "[%s issue]", "#", 54321)
|
||||
test("test (#9801 extra) issue", "test (%s extra) issue", "#", 9801)
|
||||
test("test (!9801 extra) issue", "test (%s extra) issue", "!", 9801)
|
||||
test("test (#1)", "test (%s)", "#", 1)
|
||||
|
||||
// should render multiple issue mentions in the same line
|
||||
test("#54321 #1243", "%s %s", "#", 54321, 1243)
|
||||
test("wow (#54321 #1243)", "wow (%s %s)", "#", 54321, 1243)
|
||||
test("(#4)(#5)", "(%s)(%s)", "#", 4, 5)
|
||||
test("#1 (#4321) test", "%s (%s) test", "#", 1, 4321)
|
||||
|
||||
// should render with :
|
||||
test("#1234: test", "%s: test", "#", 1234)
|
||||
test("wow (#54321: test)", "wow (%s: test)", "#", 54321)
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern3(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
|
||||
// alphanumeric: render inputs without valid mentions
|
||||
test := func(s string) {
|
||||
testRenderIssueIndexPattern(t, s, s, NewTestRenderContext(alphanumericMetas))
|
||||
}
|
||||
test("")
|
||||
test("this is a test")
|
||||
test("test 123 123 1234")
|
||||
test("#")
|
||||
test("# 123")
|
||||
test("#abcd")
|
||||
test("test #123")
|
||||
test("abc-1234") // issue prefix must be capital
|
||||
test("ABc-1234") // issue prefix must be _all_ capital
|
||||
test("ABCDEFGHIJK-1234") // the limit is 10 characters in the prefix
|
||||
test("ABC1234") // dash is required
|
||||
test("test ABC- test") // number is required
|
||||
test("test -1234 test") // prefix is required
|
||||
test("testABC-123 test") // leading space is required
|
||||
test("test ABC-123test") // trailing space is required
|
||||
test("ABC-0123") // no leading zero
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern4(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
|
||||
// alphanumeric: render inputs with valid mentions
|
||||
test := func(s, expectedFmt string, names ...string) {
|
||||
links := make([]any, len(names))
|
||||
for i, name := range names {
|
||||
links[i] = externalIssueLink("https://someurl.com/someUser/someRepo/", "ref-issue ref-external-issue", name)
|
||||
}
|
||||
expected := fmt.Sprintf(expectedFmt, links...)
|
||||
testRenderIssueIndexPattern(t, s, expected, NewTestRenderContext(alphanumericMetas))
|
||||
}
|
||||
test("OTT-1234 test", "%s test", "OTT-1234")
|
||||
test("test T-12 issue", "test %s issue", "T-12")
|
||||
test("test issue ABCDEFGHIJ-1234567890", "test issue %s", "ABCDEFGHIJ-1234567890")
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern5(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
|
||||
// regexp: render inputs without valid mentions
|
||||
test := func(s, expectedFmt, pattern string, ids, names []string) {
|
||||
metas := regexpMetas
|
||||
metas["regexp"] = pattern
|
||||
links := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
links[i] = link("https://someurl.com/someUser/someRepo/"+id, "ref-issue ref-external-issue", names[i])
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf(expectedFmt, links...)
|
||||
testRenderIssueIndexPattern(t, s, expected, NewTestRenderContext(metas))
|
||||
}
|
||||
|
||||
test("abc ISSUE-123 def", "abc %s def",
|
||||
"ISSUE-(\\d+)",
|
||||
[]string{"123"},
|
||||
[]string{"ISSUE-123"},
|
||||
)
|
||||
|
||||
test("abc (ISSUE 123) def", "abc %s def",
|
||||
"\\(ISSUE (\\d+)\\)",
|
||||
[]string{"123"},
|
||||
[]string{"(ISSUE 123)"},
|
||||
)
|
||||
|
||||
test("abc ISSUE-123 def", "abc %s def",
|
||||
"(ISSUE-(\\d+))",
|
||||
[]string{"ISSUE-123"},
|
||||
[]string{"ISSUE-123"},
|
||||
)
|
||||
|
||||
testRenderIssueIndexPattern(t, "will not match", "will not match", NewTestRenderContext(regexpMetas))
|
||||
}
|
||||
|
||||
func TestRender_IssueIndexPattern_NoShortPattern(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
metas := map[string]string{
|
||||
"format": "https://someurl.com/{user}/{repo}/{index}",
|
||||
"user": "someUser",
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleNumeric,
|
||||
}
|
||||
|
||||
testRenderIssueIndexPattern(t, "#1", "#1", NewTestRenderContext(metas))
|
||||
testRenderIssueIndexPattern(t, "#1312", "#1312", NewTestRenderContext(metas))
|
||||
testRenderIssueIndexPattern(t, "!1", "!1", NewTestRenderContext(metas))
|
||||
}
|
||||
|
||||
func TestRender_PostProcessIssueTitle(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
metas := map[string]string{
|
||||
"format": "https://someurl.com/{user}/{repo}/{index}",
|
||||
"user": "someUser",
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleNumeric,
|
||||
}
|
||||
actual, err := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "#1", actual)
|
||||
}
|
||||
|
||||
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
|
||||
var buf strings.Builder
|
||||
err := postProcess(ctx, []processor{issueIndexPatternProcessor}, strings.NewReader(input), &buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, buf.String(), "input=%q", input)
|
||||
}
|
||||
|
||||
func TestRender_AutoLink(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
|
||||
test := func(input, expected string) {
|
||||
var buffer strings.Builder
|
||||
err := PostProcessDefault(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
|
||||
|
||||
buffer.Reset()
|
||||
err = PostProcessDefault(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
|
||||
}
|
||||
|
||||
// render valid issue URLs
|
||||
test(TestRepoURL+"issues/3333",
|
||||
numericIssueLink(TestRepoURL+"issues", "ref-issue", 3333, "#"))
|
||||
|
||||
// render valid commit URLs
|
||||
tmp := TestRepoURL + "commit/d8a994ef243349f321568f9e36d5c3f444b99cae"
|
||||
test(tmp, "<a href=\""+tmp+"\" class=\"commit\"><code>d8a994ef24</code></a>")
|
||||
tmp += "#diff-2"
|
||||
test(tmp, "<a href=\""+tmp+"\" class=\"commit\"><code>d8a994ef24 (diff-2)</code></a>")
|
||||
|
||||
// render other commit URLs
|
||||
tmp = "https://external-link.gitea.io/go-gitea/gitea/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2"
|
||||
test(tmp, "<a href=\""+tmp+"\">"+tmp+"</a>")
|
||||
}
|
||||
|
||||
func TestRender_FullIssueURLs(t *testing.T) {
|
||||
setting.AppURL = TestAppURL
|
||||
defer testModule.MockVariableValue(&RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
var result strings.Builder
|
||||
err := postProcess(NewTestRenderContext(localMetas), []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result.String())
|
||||
}
|
||||
test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
|
||||
"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
|
||||
test("Look here http://localhost:3000/person/repo/issues/4",
|
||||
`Look here <a href="http://localhost:3000/person/repo/issues/4" class="ref-issue">person/repo#4</a>`)
|
||||
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
|
||||
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
|
||||
test("http://localhost:3000/test-owner/test-repo/issues/4",
|
||||
`<a href="http://localhost:3000/test-owner/test-repo/issues/4" class="ref-issue">#4</a>`)
|
||||
test("http://localhost:3000/test-owner/test-repo/issues/4 test",
|
||||
`<a href="http://localhost:3000/test-owner/test-repo/issues/4" class="ref-issue">#4</a> test`)
|
||||
test("http://localhost:3000/test-owner/test-repo/issues/4?a=1&b=2#comment-123 test",
|
||||
`<a href="http://localhost:3000/test-owner/test-repo/issues/4?a=1&b=2#comment-123" class="ref-issue">#4 (comment)</a> test`)
|
||||
test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24",
|
||||
"http://localhost:3000/testOrg/testOrgRepo/pulls/2/files#issuecomment-24")
|
||||
test("http://localhost:3000/testOrg/testOrgRepo/pulls/2/files",
|
||||
"http://localhost:3000/testOrg/testOrgRepo/pulls/2/files")
|
||||
}
|
||||
|
||||
func TestRegExp_sha1CurrentPattern(t *testing.T) {
|
||||
trueTestCases := []string{
|
||||
"d8a994ef243349f321568f9e36d5c3f444b99cae",
|
||||
"abcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"(abcdefabcdefabcdefabcdefabcdefabcdefabcd)",
|
||||
"[abcdefabcdefabcdefabcdefabcdefabcdefabcd]",
|
||||
"abcdefabcdefabcdefabcdefabcdefabcdefabcd.",
|
||||
"abcdefabcdefabcdefabcdefabcdefabcdefabcd:",
|
||||
}
|
||||
falseTestCases := []string{
|
||||
"test",
|
||||
"abcdefg",
|
||||
"e59ff077-2d03-4e6b-964d-63fbaea81f",
|
||||
"abcdefghijklmnopqrstuvwxyzabcdefghijklmn",
|
||||
"abcdefghijklmnopqrstuvwxyzabcdefghijklmO",
|
||||
}
|
||||
|
||||
for _, testCase := range trueTestCases {
|
||||
assert.True(t, globalVars().hashCurrentPattern.MatchString(testCase))
|
||||
}
|
||||
for _, testCase := range falseTestCases {
|
||||
assert.False(t, globalVars().hashCurrentPattern.MatchString(testCase))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegExp_anySHA1Pattern(t *testing.T) {
|
||||
testCases := map[string]anyHashPatternResult{
|
||||
"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": {
|
||||
CommitID: "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
|
||||
SubPath: "/test/unit/event.js",
|
||||
QueryHash: "L2703",
|
||||
},
|
||||
"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": {
|
||||
CommitID: "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
|
||||
SubPath: "/test/unit/event.js",
|
||||
},
|
||||
"https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": {
|
||||
CommitID: "0705be475092aede1eddae01319ec931fb9c65fc",
|
||||
},
|
||||
"https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": {
|
||||
CommitID: "0705be475092aede1eddae01319ec931fb9c65fc",
|
||||
SubPath: "/src",
|
||||
},
|
||||
"https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": {
|
||||
CommitID: "d8a994ef243349f321568f9e36d5c3f444b99cae",
|
||||
QueryHash: "diff-2",
|
||||
},
|
||||
"non-url": {},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b#L1-L2": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
QueryHash: "L1-L2",
|
||||
},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678.": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678/sub.": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
SubPath: "/sub",
|
||||
},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b.": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b&c=d": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
},
|
||||
"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678#hash.": {
|
||||
CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
|
||||
QueryHash: "hash",
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range testCases {
|
||||
ret, ok := anyHashPatternExtract(k)
|
||||
if v.CommitID == "" {
|
||||
assert.False(t, ok)
|
||||
} else {
|
||||
assert.Equal(t, strings.TrimSuffix(k, "."), ret.FullURL)
|
||||
assert.Equal(t, v.CommitID, ret.CommitID)
|
||||
assert.Equal(t, v.SubPath, ret.SubPath)
|
||||
assert.Equal(t, v.QueryHash, ret.QueryHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegExp_shortLinkPattern(t *testing.T) {
|
||||
trueTestCases := []string{
|
||||
"[[stuff]]",
|
||||
"[[]]",
|
||||
"[[stuff|title=Difficult name with spaces*!]]",
|
||||
}
|
||||
falseTestCases := []string{
|
||||
"test",
|
||||
"abcdefg",
|
||||
"[[]",
|
||||
"[[",
|
||||
"[]",
|
||||
"]]",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
}
|
||||
|
||||
for _, testCase := range trueTestCases {
|
||||
assert.True(t, globalVars().shortLinkPattern.MatchString(testCase))
|
||||
}
|
||||
for _, testCase := range falseTestCases {
|
||||
assert.False(t, globalVars().shortLinkPattern.MatchString(testCase))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/regexplru"
|
||||
"gitea.dev/modules/templates/vars"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
type RenderIssueIconTitleOptions struct {
|
||||
OwnerName string
|
||||
RepoName string
|
||||
LinkHref string
|
||||
IssueIndex int64
|
||||
}
|
||||
|
||||
func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if ctx.RenderOptions.Metas == nil {
|
||||
return
|
||||
}
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
m := globalVars().issueFullPattern.FindStringSubmatchIndex(node.Data)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mDiffView := globalVars().filesChangedFullPattern.FindStringSubmatchIndex(node.Data)
|
||||
// leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files
|
||||
if mDiffView != nil {
|
||||
return
|
||||
}
|
||||
|
||||
link := node.Data[m[0]:m[1]]
|
||||
if !httplib.IsCurrentGiteaSiteURL(ctx, link) {
|
||||
return
|
||||
}
|
||||
text := "#" + node.Data[m[2]:m[3]]
|
||||
// if m[4] and m[5] is not -1, then link is to a comment
|
||||
// indicate that in the text by appending (comment)
|
||||
if m[4] != -1 && m[5] != -1 {
|
||||
if locale, ok := ctx.Value(translation.ContextKey).(translation.Locale); ok {
|
||||
text += " " + locale.TrString("repo.from_comment")
|
||||
} else {
|
||||
text += " (comment)"
|
||||
}
|
||||
}
|
||||
|
||||
// extract repo and org name from matched link like
|
||||
// http://localhost:3000/gituser/myrepo/issues/1
|
||||
linkParts := strings.Split(link, "/")
|
||||
matchOrg := linkParts[len(linkParts)-4]
|
||||
matchRepo := linkParts[len(linkParts)-3]
|
||||
|
||||
if matchOrg == ctx.RenderOptions.Metas["user"] && matchRepo == ctx.RenderOptions.Metas["repo"] {
|
||||
replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue"))
|
||||
} else {
|
||||
text = matchOrg + "/" + matchRepo + text
|
||||
replaceContent(node, m[0], m[1], createLink(ctx, link, text, "ref-issue"))
|
||||
}
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
func createIssueLinkContentWithSummary(ctx *RenderContext, linkHref string, ref *references.RenderizableReference) *html.Node {
|
||||
if DefaultRenderHelperFuncs.RenderRepoIssueIconTitle == nil {
|
||||
return nil
|
||||
}
|
||||
issueIndex, _ := strconv.ParseInt(ref.Issue, 10, 64)
|
||||
h, err := DefaultRenderHelperFuncs.RenderRepoIssueIconTitle(ctx, RenderIssueIconTitleOptions{
|
||||
OwnerName: ref.Owner,
|
||||
RepoName: ref.Name,
|
||||
LinkHref: ctx.RenderHelper.ResolveLink(linkHref, LinkTypeDefault),
|
||||
IssueIndex: issueIndex,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("RenderRepoIssueIconTitle failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
if h == "" {
|
||||
return nil
|
||||
}
|
||||
return &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(h))}
|
||||
}
|
||||
|
||||
func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if ctx.RenderOptions.Metas == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// crossLinkOnly: do not parse "#123", only parse "owner/repo#123"
|
||||
// if there is no repo in the context, then the "#123" format can't be parsed
|
||||
// old logic: crossLinkOnly := ctx.RenderOptions.Metas["mode"] == "document" && !ctx.IsWiki
|
||||
crossLinkOnly := ctx.RenderOptions.Metas["markupAllowShortIssuePattern"] != "true"
|
||||
|
||||
var ref *references.RenderizableReference
|
||||
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
_, hasExtTrackFormat := ctx.RenderOptions.Metas["format"]
|
||||
|
||||
// Repos with external issue trackers might still need to reference local PRs
|
||||
// We need to concern with the first one that shows up in the text, whichever it is
|
||||
isNumericStyle := ctx.RenderOptions.Metas["style"] == "" || ctx.RenderOptions.Metas["style"] == IssueNameStyleNumeric
|
||||
refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
|
||||
|
||||
switch ctx.RenderOptions.Metas["style"] {
|
||||
case "", IssueNameStyleNumeric:
|
||||
ref = refNumeric
|
||||
case IssueNameStyleAlphanumeric:
|
||||
ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
|
||||
case IssueNameStyleRegexp:
|
||||
pattern, err := regexplru.GetCompiled(ctx.RenderOptions.Metas["regexp"])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
|
||||
}
|
||||
|
||||
// Repos with external issue trackers might still need to reference local PRs
|
||||
// We need to concern with the first one that shows up in the text, whichever it is
|
||||
if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
|
||||
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
|
||||
// Allow a free-pass when non-numeric pattern wasn't found.
|
||||
if ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start {
|
||||
ref = refNumeric
|
||||
}
|
||||
}
|
||||
|
||||
if ref == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var link *html.Node
|
||||
refText := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
|
||||
if hasExtTrackFormat && !ref.IsPull {
|
||||
ctx.RenderOptions.Metas["index"] = ref.Issue
|
||||
|
||||
res, err := vars.Expand(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas)
|
||||
if err != nil {
|
||||
// here we could just log the error and continue the rendering
|
||||
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
|
||||
}
|
||||
|
||||
link = createLink(ctx, res, refText, "ref-issue ref-external-issue")
|
||||
} else {
|
||||
// Path determines the type of link that will be rendered. It's unknown at this point whether
|
||||
// the linked item is actually a PR or an issue. Luckily it's of no real consequence because
|
||||
// Gitea will redirect on click as appropriate.
|
||||
issueOwner := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["user"], ref.Owner)
|
||||
issueRepo := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["repo"], ref.Name)
|
||||
issuePath := util.Iif(ref.IsPull, "pulls", "issues")
|
||||
linkHref := fmt.Sprintf("/:root/%s/%s/%s/%s", issueOwner, issueRepo, issuePath, ref.Issue)
|
||||
|
||||
// at the moment, only render the issue index in a full line (or simple line) as icon+title
|
||||
// otherwise it would be too noisy for "take #1 as an example" in a sentence
|
||||
if node.Parent.DataAtom == atom.Li && ref.RefLocation.Start < 20 && ref.RefLocation.End == len(node.Data) {
|
||||
link = createIssueLinkContentWithSummary(ctx, linkHref, ref)
|
||||
}
|
||||
if link == nil {
|
||||
link = createLink(ctx, linkHref, refText, "ref-issue")
|
||||
}
|
||||
}
|
||||
|
||||
if ref.Action == references.XRefActionNone {
|
||||
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
|
||||
node = node.NextSibling.NextSibling
|
||||
continue
|
||||
}
|
||||
|
||||
// Decorate action keywords if actionable
|
||||
var keyword *html.Node
|
||||
if references.IsXrefActionable(ref, hasExtTrackFormat) {
|
||||
keyword = createKeyword(ctx, node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
|
||||
} else {
|
||||
keyword = &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
|
||||
}
|
||||
}
|
||||
spaces := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],
|
||||
}
|
||||
replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link})
|
||||
node = node.NextSibling.NextSibling.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
testModule "gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRender_IssueList(t *testing.T) {
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
RenderRepoIssueIconTitle: func(ctx context.Context, opts markup.RenderIssueIconTitleOptions) (template.HTML, error) {
|
||||
return htmlutil.HTMLFormat("<div>issue #%d</div>", opts.IssueIndex), nil
|
||||
},
|
||||
})
|
||||
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, map[string]string{
|
||||
"user": "test-user", "repo": "test-repo",
|
||||
"markupAllowShortIssuePattern": "true",
|
||||
"footnoteContextId": "12345",
|
||||
})
|
||||
out, err := markdown.RenderString(rctx, input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
t.Run("NormalIssueRef", func(t *testing.T) {
|
||||
test(
|
||||
"#12345",
|
||||
`<p><a href="/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a></p>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("ListIssueRef", func(t *testing.T) {
|
||||
test(
|
||||
"* #12345",
|
||||
`<ul>
|
||||
<li><div>issue #12345</div></li>
|
||||
</ul>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("ListIssueRefNormal", func(t *testing.T) {
|
||||
test(
|
||||
"* foo #12345 bar",
|
||||
`<ul>
|
||||
<li>foo <a href="/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a> bar</li>
|
||||
</ul>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("ListTodoIssueRef", func(t *testing.T) {
|
||||
test(
|
||||
"* [ ] #12345",
|
||||
`<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="2"/><div>issue #12345</div></li>
|
||||
</ul>`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("IssueFootnote", func(t *testing.T) {
|
||||
test(
|
||||
"foo[^1][^2]\n\n[^1]: bar\n[^2]: baz",
|
||||
`<p>foo<sup id="fnref:user-content-1-12345"><a href="#fn:user-content-1-12345" rel="nofollow">1 </a></sup><sup id="fnref:user-content-2-12345"><a href="#fn:user-content-2-12345" rel="nofollow">2 </a></sup></p>
|
||||
<div>
|
||||
<hr/>
|
||||
<ol>
|
||||
<li id="fn:user-content-1-12345">
|
||||
<p>bar <a href="#fnref:user-content-1-12345" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
<li id="fn:user-content-2-12345">
|
||||
<p>baz <a href="#fnref:user-content-2-12345" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
m := globalVars().shortLinkPattern.FindStringSubmatchIndex(node.Data)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
content := node.Data[m[2]:m[3]]
|
||||
tail := node.Data[m[4]:m[5]]
|
||||
props := make(map[string]string)
|
||||
|
||||
// MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
|
||||
// It makes page handling terrible, but we prefer GitHub syntax
|
||||
// And fall back to MediaWiki only when it is obvious from the look
|
||||
// Of text and link contents
|
||||
sl := strings.SplitSeq(content, "|")
|
||||
for v := range sl {
|
||||
if found := strings.Contains(v, "="); !found {
|
||||
// There is no equal in this argument; this is a mandatory arg
|
||||
if props["name"] == "" {
|
||||
if IsFullURLString(v) {
|
||||
// If we clearly see it is a link, we save it so
|
||||
|
||||
// But first we need to ensure, that if both mandatory args provided
|
||||
// look like links, we stick to GitHub syntax
|
||||
if props["link"] != "" {
|
||||
props["name"] = props["link"]
|
||||
}
|
||||
|
||||
props["link"] = strings.TrimSpace(v)
|
||||
} else {
|
||||
props["name"] = v
|
||||
}
|
||||
} else {
|
||||
props["link"] = strings.TrimSpace(v)
|
||||
}
|
||||
} else {
|
||||
// There is an equal; optional argument.
|
||||
|
||||
before, after, _ := strings.Cut(v, "=")
|
||||
key, val := before, html.UnescapeString(after)
|
||||
|
||||
// When parsing HTML, x/net/html will change all quotes which are
|
||||
// not used for syntax into UTF-8 quotes. So checking val[0] won't
|
||||
// be enough, since that only checks a single byte.
|
||||
if len(val) > 1 {
|
||||
if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "”")) ||
|
||||
(strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) {
|
||||
const lenQuote = len("‘")
|
||||
val = val[lenQuote : len(val)-lenQuote]
|
||||
} else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) ||
|
||||
(strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) {
|
||||
val = val[1 : len(val)-1]
|
||||
} else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") {
|
||||
const lenQuote = len("‘")
|
||||
val = val[1 : len(val)-lenQuote]
|
||||
}
|
||||
}
|
||||
props[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
var name, link string
|
||||
if props["link"] != "" {
|
||||
link = props["link"]
|
||||
} else if props["name"] != "" {
|
||||
link = props["name"]
|
||||
}
|
||||
if props["title"] != "" {
|
||||
name = props["title"]
|
||||
} else if props["name"] != "" {
|
||||
name = props["name"]
|
||||
} else {
|
||||
name = link
|
||||
}
|
||||
|
||||
name += tail
|
||||
image := false
|
||||
ext := path.Ext(link)
|
||||
switch ext {
|
||||
// fast path: empty string, ignore
|
||||
case "":
|
||||
// leave image as false
|
||||
case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
|
||||
image = true
|
||||
}
|
||||
|
||||
childNode := &html.Node{}
|
||||
linkNode := &html.Node{
|
||||
FirstChild: childNode,
|
||||
LastChild: childNode,
|
||||
Type: html.ElementNode,
|
||||
Data: "a",
|
||||
DataAtom: atom.A,
|
||||
}
|
||||
childNode.Parent = linkNode
|
||||
absoluteLink := IsFullURLString(link)
|
||||
// FIXME: it should be fully refactored in the future, it uses various hacky approaches to guess how to encode a path for wiki
|
||||
// When a link contains "/", then we assume that the user has provided a well-encoded link.
|
||||
if !absoluteLink && !strings.Contains(link, "/") {
|
||||
// So only guess for links without "/".
|
||||
if image {
|
||||
link = strings.ReplaceAll(link, " ", "+")
|
||||
} else {
|
||||
// the hacky wiki name encoding: space to "-"
|
||||
link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-"
|
||||
}
|
||||
link = url.PathEscape(link)
|
||||
}
|
||||
if image {
|
||||
title := props["title"]
|
||||
if title == "" {
|
||||
title = props["alt"]
|
||||
}
|
||||
if title == "" {
|
||||
title = path.Base(name)
|
||||
}
|
||||
alt := props["alt"]
|
||||
if alt == "" {
|
||||
alt = name
|
||||
}
|
||||
|
||||
// make the childNode an image - if we can, we also place the alt
|
||||
childNode.Type = html.ElementNode
|
||||
childNode.Data = "img"
|
||||
childNode.DataAtom = atom.Img
|
||||
childNode.Attr = []html.Attribute{
|
||||
{Key: "src", Val: link},
|
||||
{Key: "title", Val: title},
|
||||
{Key: "alt", Val: alt},
|
||||
}
|
||||
if alt == "" {
|
||||
childNode.Attr = childNode.Attr[:2]
|
||||
}
|
||||
} else {
|
||||
childNode.Type = html.TextNode
|
||||
childNode.Data = name
|
||||
}
|
||||
linkNode.Attr = []html.Attribute{{Key: "href", Val: link}}
|
||||
replaceContent(node, m[0], m[1], linkNode)
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
// linkProcessor creates links for any HTTP or HTTPS URL not captured by
|
||||
// markdown.
|
||||
func linkProcessor(ctx *RenderContext, node *html.Node) {
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
uri := node.Data[m[0]:m[1]]
|
||||
remaining := node.Data[m[1]:]
|
||||
if util.IsLikelyEllipsisLeftPart(remaining) {
|
||||
return
|
||||
}
|
||||
replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
// descriptionLinkProcessor creates links for DescriptionHTML
|
||||
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
|
||||
next := node.NextSibling
|
||||
for node != nil && node != next {
|
||||
m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
uri := node.Data[m[0]:m[1]]
|
||||
replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri))
|
||||
node = node.NextSibling.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
func createDescriptionLink(href, content string) *html.Node {
|
||||
textNode := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
linkNode := &html.Node{
|
||||
FirstChild: textNode,
|
||||
LastChild: textNode,
|
||||
Type: html.ElementNode,
|
||||
Data: "a",
|
||||
DataAtom: atom.A,
|
||||
Attr: []html.Attribute{
|
||||
{Key: "href", Val: href},
|
||||
{Key: "target", Val: "_blank"},
|
||||
},
|
||||
}
|
||||
textNode.Parent = linkNode
|
||||
return linkNode
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func mentionProcessor(ctx *RenderContext, node *html.Node) {
|
||||
start := 0
|
||||
nodeStop := node.NextSibling
|
||||
for node != nodeStop {
|
||||
found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:]))
|
||||
if !found {
|
||||
node = node.NextSibling
|
||||
start = 0
|
||||
continue
|
||||
}
|
||||
loc.Start += start
|
||||
loc.End += start
|
||||
mention := node.Data[loc.Start:loc.End]
|
||||
teams, ok := ctx.RenderOptions.Metas["teams"]
|
||||
|
||||
if ok && strings.Contains(mention, "/") {
|
||||
mentionOrgAndTeam := strings.Split(mention, "/")
|
||||
if mentionOrgAndTeam[0][1:] == ctx.RenderOptions.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
|
||||
link := fmt.Sprintf("/:root/org/%s/teams/%s", ctx.RenderOptions.Metas["org"], mentionOrgAndTeam[1])
|
||||
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0
|
||||
continue
|
||||
}
|
||||
start = loc.End
|
||||
continue
|
||||
}
|
||||
mentionedUsername := mention[1:]
|
||||
|
||||
if DefaultRenderHelperFuncs != nil && DefaultRenderHelperFuncs.IsUsernameMentionable(ctx, mentionedUsername) {
|
||||
link := "/:root/" + mentionedUsername
|
||||
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0
|
||||
} else {
|
||||
start = loc.End
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup/common"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func isAnchorIDUserContent(s string) bool {
|
||||
// blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote
|
||||
// old logic: blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`)
|
||||
return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") || isAnchorIDFootnote(s)
|
||||
}
|
||||
|
||||
func isAnchorIDFootnote(s string) bool {
|
||||
return strings.HasPrefix(s, "fnref:user-content-") || strings.HasPrefix(s, "fn:user-content-")
|
||||
}
|
||||
|
||||
func isAnchorHrefFootnote(s string) bool {
|
||||
return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-")
|
||||
}
|
||||
|
||||
// isHeadingTag returns true if the node is a heading tag (h1-h6)
|
||||
func isHeadingTag(node *html.Node) bool {
|
||||
return node.Type == html.ElementNode &&
|
||||
len(node.Data) == 2 &&
|
||||
node.Data[0] == 'h' &&
|
||||
node.Data[1] >= '1' && node.Data[1] <= '6'
|
||||
}
|
||||
|
||||
// getNodeText extracts the text content from a node and its children
|
||||
func getNodeText(node *html.Node, cached **string) string {
|
||||
if *cached != nil {
|
||||
return **cached
|
||||
}
|
||||
var text strings.Builder
|
||||
var extractText func(*html.Node)
|
||||
extractText = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
text.WriteString(n.Data)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
extractText(c)
|
||||
}
|
||||
}
|
||||
extractText(node)
|
||||
textStr := text.String()
|
||||
*cached = &textStr
|
||||
return textStr
|
||||
}
|
||||
|
||||
func processNodeHeadingAndID(ctx *RenderContext, node *html.Node) {
|
||||
// TODO: handle duplicate IDs, need to track existing IDs in the document
|
||||
// Add user-content- to IDs and "#" links if they don't already have them,
|
||||
// and convert the link href to a relative link to the host root
|
||||
attrIDVal := ""
|
||||
for idx, attr := range node.Attr {
|
||||
if attr.Key == "id" {
|
||||
attrIDVal = attr.Val
|
||||
if !isAnchorIDUserContent(attrIDVal) {
|
||||
attrIDVal = "user-content-" + attrIDVal
|
||||
node.Attr[idx].Val = attrIDVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isHeadingTag(node) || !ctx.RenderOptions.EnableHeadingIDGeneration {
|
||||
return
|
||||
}
|
||||
|
||||
// For heading tags (h1-h6) without an id attribute, generate one from the text content.
|
||||
// This ensures HTML headings like <h1>Title</h1> get proper permalink anchors
|
||||
// matching the behavior of Markdown headings.
|
||||
// Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option.
|
||||
var nodeTextCached *string
|
||||
if attrIDVal == "" {
|
||||
nodeText := getNodeText(node, &nodeTextCached)
|
||||
if nodeText != "" {
|
||||
// Use the same CleanValue function used by Markdown heading ID generation
|
||||
attrIDVal = string(common.CleanValue([]byte(nodeText)))
|
||||
if attrIDVal != "" {
|
||||
attrIDVal = "user-content-" + attrIDVal
|
||||
node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: attrIDVal})
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.TocShowInSection != "" {
|
||||
nodeText := getNodeText(node, &nodeTextCached)
|
||||
if nodeText != "" && attrIDVal != "" {
|
||||
ctx.TocHeadingItems = append(ctx.TocHeadingItems, &TocHeadingItem{
|
||||
HeadingLevel: int(node.Data[1] - '0'),
|
||||
AnchorID: attrIDVal,
|
||||
InnerText: nodeText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processFootnoteNode(ctx *RenderContext, node *html.Node) {
|
||||
for idx, attr := range node.Attr {
|
||||
if (attr.Key == "id" && isAnchorIDFootnote(attr.Val)) ||
|
||||
(attr.Key == "href" && isAnchorHrefFootnote(attr.Val)) {
|
||||
if footnoteContextID := ctx.RenderOptions.Metas["footnoteContextId"]; footnoteContextID != "" {
|
||||
node.Attr[idx].Val = attr.Val + "-" + footnoteContextID
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processNodeA(ctx *RenderContext, node *html.Node) {
|
||||
for idx, attr := range node.Attr {
|
||||
if attr.Key == "href" {
|
||||
if anchorID, ok := strings.CutPrefix(attr.Val, "#"); ok {
|
||||
if !isAnchorIDUserContent(attr.Val) {
|
||||
node.Attr[idx].Val = "#user-content-" + anchorID
|
||||
}
|
||||
} else {
|
||||
node.Attr[idx].Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
|
||||
next = img.NextSibling
|
||||
attrSrc, hasLazy := "", false
|
||||
for i, imgAttr := range img.Attr {
|
||||
hasLazy = hasLazy || imgAttr.Key == "loading" && imgAttr.Val == "lazy"
|
||||
if imgAttr.Key != "src" {
|
||||
attrSrc = imgAttr.Val
|
||||
continue
|
||||
}
|
||||
|
||||
imgSrcOrigin := imgAttr.Val
|
||||
isLinkable := imgSrcOrigin != "" && !strings.HasPrefix(imgSrcOrigin, "data:")
|
||||
|
||||
// By default, the "<img>" tag should also be clickable,
|
||||
// because frontend uses `<img>` to paste the re-scaled image into the Markdown,
|
||||
// so it must match the default Markdown image behavior.
|
||||
cnt := 0
|
||||
for p := img.Parent; isLinkable && p != nil && cnt < 2; p = p.Parent {
|
||||
if hasParentAnchor := p.Type == html.ElementNode && p.Data == "a"; hasParentAnchor {
|
||||
isLinkable = false
|
||||
break
|
||||
}
|
||||
cnt++
|
||||
}
|
||||
if isLinkable {
|
||||
wrapper := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
|
||||
{Key: "href", Val: ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeDefault)},
|
||||
{Key: "target", Val: "_blank"},
|
||||
}}
|
||||
parent := img.Parent
|
||||
imgNext := img.NextSibling
|
||||
parent.RemoveChild(img)
|
||||
parent.InsertBefore(wrapper, imgNext)
|
||||
wrapper.AppendChild(img)
|
||||
}
|
||||
|
||||
imgAttr.Val = ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeMedia)
|
||||
imgAttr.Val = camoHandleLink(imgAttr.Val)
|
||||
img.Attr[i] = imgAttr
|
||||
}
|
||||
if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") {
|
||||
img.Attr = append(img.Attr, html.Attribute{Key: "loading", Val: "lazy"})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
|
||||
next = node.NextSibling
|
||||
for i, attr := range node.Attr {
|
||||
if attr.Key != "src" {
|
||||
continue
|
||||
}
|
||||
if IsNonEmptyRelativePath(attr.Val) {
|
||||
attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia)
|
||||
}
|
||||
attr.Val = camoHandleLink(attr.Val)
|
||||
node.Attr[i] = attr
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestProcessNodeAttrID_HTMLHeadingWithoutID(t *testing.T) {
|
||||
// Test that HTML headings without id get an auto-generated id from their text content
|
||||
// when EnableHeadingIDGeneration is true (for repo files and wiki pages)
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "h1 without id",
|
||||
input: `<h1>Heading without ID</h1>`,
|
||||
expected: `<h1 id="user-content-heading-without-id">Heading without ID</h1>`,
|
||||
},
|
||||
{
|
||||
name: "h2 without id",
|
||||
input: `<h2>Another Heading</h2>`,
|
||||
expected: `<h2 id="user-content-another-heading">Another Heading</h2>`,
|
||||
},
|
||||
{
|
||||
name: "h3 without id",
|
||||
input: `<h3>Third Level</h3>`,
|
||||
expected: `<h3 id="user-content-third-level">Third Level</h3>`,
|
||||
},
|
||||
{
|
||||
name: "h1 with existing id should keep it",
|
||||
input: `<h1 id="my-custom-id">Heading with ID</h1>`,
|
||||
expected: `<h1 id="user-content-my-custom-id">Heading with ID</h1>`,
|
||||
},
|
||||
{
|
||||
name: "h1 with user-content prefix should not double prefix",
|
||||
input: `<h1 id="user-content-already-prefixed">Already Prefixed</h1>`,
|
||||
expected: `<h1 id="user-content-already-prefixed">Already Prefixed</h1>`,
|
||||
},
|
||||
{
|
||||
name: "heading with special characters",
|
||||
input: `<h1>What is Wine Staging?</h1>`,
|
||||
expected: `<h1 id="user-content-what-is-wine-staging">What is Wine Staging?</h1>`,
|
||||
},
|
||||
{
|
||||
name: "heading with nested elements",
|
||||
input: `<h2><strong>Bold</strong> and <em>Italic</em></h2>`,
|
||||
expected: `<h2 id="user-content-bold-and-italic"><strong>Bold</strong> and <em>Italic</em></h2>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var result strings.Builder
|
||||
ctx := NewTestRenderContext().WithEnableHeadingIDGeneration(true)
|
||||
err := PostProcessDefault(ctx, strings.NewReader(tc.input), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, strings.TrimSpace(result.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessNodeAttrID_SkipHeadingIDForComments(t *testing.T) {
|
||||
// Test that HTML headings in comment-like contexts (issue comments)
|
||||
// do NOT get auto-generated IDs to avoid duplicate IDs on pages with multiple documents.
|
||||
// This is controlled by EnableHeadingIDGeneration which defaults to false.
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "h1 without id in comment context",
|
||||
input: `<h1>Heading without ID</h1>`,
|
||||
expected: `<h1>Heading without ID</h1>`,
|
||||
},
|
||||
{
|
||||
name: "h2 without id in comment context",
|
||||
input: `<h2>Another Heading</h2>`,
|
||||
expected: `<h2>Another Heading</h2>`,
|
||||
},
|
||||
{
|
||||
name: "h1 with existing id should still be prefixed",
|
||||
input: `<h1 id="my-custom-id">Heading with ID</h1>`,
|
||||
expected: `<h1 id="user-content-my-custom-id">Heading with ID</h1>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var result strings.Builder
|
||||
// Default context without EnableHeadingIDGeneration (simulates comment rendering)
|
||||
err := PostProcessDefault(NewTestRenderContext(), strings.NewReader(tc.input), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, strings.TrimSpace(result.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
testModule "gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var (
|
||||
testRepoOwnerName = "user13"
|
||||
testRepoName = "repo11"
|
||||
localMetas = map[string]string{"user": testRepoOwnerName, "repo": testRepoName}
|
||||
)
|
||||
|
||||
func testRenderString(ctx *markup.RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
err := markup.Render(ctx, strings.NewReader(content), &buf)
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestRender_Commits(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
|
||||
buffer, err := testRenderString(rctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
|
||||
repo := markup.TestAppURL + testRepoOwnerName + "/" + testRepoName + "/"
|
||||
commit := repo + "commit/" + sha
|
||||
commitPath := "/user13/repo11/commit/" + sha
|
||||
tree := repo + "tree/" + sha + "/src"
|
||||
|
||||
file := repo + "commit/" + sha + "/example.txt"
|
||||
fileWithExtra := file + ":"
|
||||
fileWithHash := file + "#L2"
|
||||
fileWithHasExtra := file + "#L2:"
|
||||
commitCompare := repo + "compare/" + sha + "..." + sha
|
||||
commitCompareWithHash := commitCompare + "#L2"
|
||||
|
||||
test(sha, `<p><a href="`+commitPath+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
|
||||
test(sha[:7], `<p><a href="`+commitPath[:len(commitPath)-(40-7)]+`" rel="nofollow"><code>65f1bf2</code></a></p>`)
|
||||
test(sha[:39], `<p><a href="`+commitPath[:len(commitPath)-(40-39)]+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
|
||||
test(commit, `<p><a href="`+commit+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
|
||||
test(tree, `<p><a href="`+tree+`" rel="nofollow"><code>65f1bf27bc/src</code></a></p>`)
|
||||
|
||||
test(file, `<p><a href="`+file+`" rel="nofollow"><code>65f1bf27bc/example.txt</code></a></p>`)
|
||||
test(fileWithExtra, `<p><a href="`+file+`" rel="nofollow"><code>65f1bf27bc/example.txt</code></a>:</p>`)
|
||||
test(fileWithHash, `<p><a href="`+fileWithHash+`" rel="nofollow"><code>65f1bf27bc/example.txt (L2)</code></a></p>`)
|
||||
test(fileWithHasExtra, `<p><a href="`+fileWithHash+`" rel="nofollow"><code>65f1bf27bc/example.txt (L2)</code></a>:</p>`)
|
||||
test(commitCompare, `<p><a href="`+commitCompare+`" rel="nofollow"><code>65f1bf27bc...65f1bf27bc</code></a></p>`)
|
||||
test(commitCompareWithHash, `<p><a href="`+commitCompareWithHash+`" rel="nofollow"><code>65f1bf27bc...65f1bf27bc (L2)</code></a></p>`)
|
||||
|
||||
test("commit "+sha, `<p>commit <a href="`+commitPath+`" rel="nofollow"><code>65f1bf27bc</code></a></p>`)
|
||||
test("/home/gitea/"+sha, "<p>/home/gitea/"+sha+"</p>")
|
||||
test("deadbeef", `<p>deadbeef</p>`)
|
||||
test("d27ace93", `<p>d27ace93</p>`)
|
||||
test(sha[:14]+".x", `<p>`+sha[:14]+`.x</p>`)
|
||||
|
||||
expected14 := `<a href="` + commitPath[:len(commitPath)-(40-14)] + `" rel="nofollow"><code>` + sha[:10] + `</code></a>`
|
||||
test(sha[:14]+".", `<p>`+expected14+`.</p>`)
|
||||
test(sha[:14]+",", `<p>`+expected14+`,</p>`)
|
||||
test("["+sha[:14]+"]", `<p>[`+expected14+`]</p>`)
|
||||
}
|
||||
|
||||
func TestRender_CrossReferences(t *testing.T) {
|
||||
defer testModule.MockVariableValue(&setting.AppURL, markup.TestAppURL)()
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
|
||||
buffer, err := testRenderString(rctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(
|
||||
"test-owner/test-repo#12345",
|
||||
`<p><a href="/test-owner/test-repo/issues/12345" class="ref-issue" rel="nofollow">test-owner/test-repo#12345</a></p>`)
|
||||
test(
|
||||
"go-gitea/gitea#12345",
|
||||
`<p><a href="/go-gitea/gitea/issues/12345" class="ref-issue" rel="nofollow">go-gitea/gitea#12345</a></p>`)
|
||||
test(
|
||||
"/home/gitea/go-gitea/gitea#12345",
|
||||
`<p>/home/gitea/go-gitea/gitea#12345</p>`)
|
||||
test(
|
||||
markup.TestAppURL+"gogitea/gitea/issues/12345",
|
||||
`<p><a href="`+markup.TestAppURL+`gogitea/gitea/issues/12345" class="ref-issue" rel="nofollow">gogitea/gitea#12345</a></p>`)
|
||||
test(
|
||||
markup.TestAppURL+"go-gitea/gitea/issues/12345",
|
||||
`<p><a href="`+markup.TestAppURL+`go-gitea/gitea/issues/12345" class="ref-issue" rel="nofollow">go-gitea/gitea#12345</a></p>`)
|
||||
test(
|
||||
markup.TestAppURL+"gogitea/some-repo-name/issues/12345",
|
||||
`<p><a href="`+markup.TestAppURL+`gogitea/some-repo-name/issues/12345" class="ref-issue" rel="nofollow">gogitea/some-repo-name#12345</a></p>`)
|
||||
|
||||
inputURL := setting.AppURL + "a/b/commit/0123456789012345678901234567890123456789/foo.txt?a=b#L2-L3"
|
||||
test(
|
||||
inputURL,
|
||||
`<p><a href="`+inputURL+`" rel="nofollow"><code>0123456789/foo.txt (L2-L3)</code></a></p>`)
|
||||
|
||||
inputURL = setting.AppURL + "repo/owner/archive/0123456789012345678901234567890123456789.tar.gz"
|
||||
test(
|
||||
inputURL,
|
||||
`<p><a href="`+inputURL+`" rel="nofollow"><code>0123456789.tar.gz</code></a></p>`)
|
||||
|
||||
inputURL = setting.AppURL + "owner/repo/commit/0123456789012345678901234567890123456789.patch?key=val"
|
||||
test(
|
||||
inputURL,
|
||||
`<p><a href="`+inputURL+`" rel="nofollow"><code>0123456789.patch</code></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_links(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
|
||||
markup.ResetDefaultSanitizerForTesting()
|
||||
defer func() {
|
||||
setting.Markdown.CustomURLSchemes = oldCustomURLSchemes
|
||||
markup.ResetDefaultSanitizerForTesting()
|
||||
markup.CustomLinkURLSchemes(oldCustomURLSchemes)
|
||||
}()
|
||||
setting.Markdown.CustomURLSchemes = []string{"ftp", "magnet"}
|
||||
markup.CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
||||
|
||||
// Text that should be turned into URL
|
||||
test(
|
||||
"https://www.example.com",
|
||||
`<p><a href="https://www.example.com" rel="nofollow">https://www.example.com</a></p>`)
|
||||
test(
|
||||
"http://www.example.com",
|
||||
`<p><a href="http://www.example.com" rel="nofollow">http://www.example.com</a></p>`)
|
||||
test(
|
||||
"https://example.com",
|
||||
`<p><a href="https://example.com" rel="nofollow">https://example.com</a></p>`)
|
||||
test(
|
||||
"http://example.com",
|
||||
`<p><a href="http://example.com" rel="nofollow">http://example.com</a></p>`)
|
||||
test(
|
||||
"http://foo.com/blah_blah",
|
||||
`<p><a href="http://foo.com/blah_blah" rel="nofollow">http://foo.com/blah_blah</a></p>`)
|
||||
test(
|
||||
"http://foo.com/blah_blah/",
|
||||
`<p><a href="http://foo.com/blah_blah/" rel="nofollow">http://foo.com/blah_blah/</a></p>`)
|
||||
test(
|
||||
"http://www.example.com/wpstyle/?p=364",
|
||||
`<p><a href="http://www.example.com/wpstyle/?p=364" rel="nofollow">http://www.example.com/wpstyle/?p=364</a></p>`)
|
||||
test(
|
||||
"https://www.example.com/foo/?bar=baz&inga=42&quux",
|
||||
`<p><a href="https://www.example.com/foo/?bar=baz&inga=42&quux" rel="nofollow">https://www.example.com/foo/?bar=baz&inga=42&quux</a></p>`)
|
||||
test(
|
||||
"http://142.42.1.1/",
|
||||
`<p><a href="http://142.42.1.1/" rel="nofollow">http://142.42.1.1/</a></p>`)
|
||||
test(
|
||||
"https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd",
|
||||
`<p><a href="https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd" rel="nofollow">https://github.com/go-gitea/gitea/?p=aaa/bbb.html#ccc-ddd</a></p>`)
|
||||
test(
|
||||
"https://en.wikipedia.org/wiki/URL_(disambiguation)",
|
||||
`<p><a href="https://en.wikipedia.org/wiki/URL_(disambiguation)" rel="nofollow">https://en.wikipedia.org/wiki/URL_(disambiguation)</a></p>`)
|
||||
test(
|
||||
"https://foo_bar.example.com/",
|
||||
`<p><a href="https://foo_bar.example.com/" rel="nofollow">https://foo_bar.example.com/</a></p>`)
|
||||
test(
|
||||
"https://stackoverflow.com/questions/2896191/what-is-go-used-fore",
|
||||
`<p><a href="https://stackoverflow.com/questions/2896191/what-is-go-used-fore" rel="nofollow">https://stackoverflow.com/questions/2896191/what-is-go-used-fore</a></p>`)
|
||||
test(
|
||||
"https://username:password@gitea.com",
|
||||
`<p><a href="https://username:password@gitea.com" rel="nofollow">https://username:password@gitea.com</a></p>`)
|
||||
test(
|
||||
"ftp://gitea.com/file.txt",
|
||||
`<p><a href="ftp://gitea.com/file.txt" rel="nofollow">ftp://gitea.com/file.txt</a></p>`)
|
||||
test(
|
||||
"magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&dn=download",
|
||||
`<p><a href="magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&dn=download" rel="nofollow">magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcadabcde&dn=download</a></p>`)
|
||||
test(
|
||||
`[link](https://example.com)`,
|
||||
`<p><a href="https://example.com" rel="nofollow">link</a></p>`)
|
||||
test(
|
||||
`[link](mailto:test@example.com)`,
|
||||
`<p><a href="mailto:test@example.com" rel="nofollow">link</a></p>`)
|
||||
test(
|
||||
`[link](javascript:xss)`,
|
||||
`<p>link</p>`)
|
||||
|
||||
// Test that should *not* be turned into URL
|
||||
test(
|
||||
"www.example.com",
|
||||
`<p>www.example.com</p>`)
|
||||
test(
|
||||
"example.com",
|
||||
`<p>example.com</p>`)
|
||||
test(
|
||||
"test.example.com",
|
||||
`<p>test.example.com</p>`)
|
||||
test(
|
||||
"http://",
|
||||
`<p>http://</p>`)
|
||||
test(
|
||||
"https://",
|
||||
`<p>https://</p>`)
|
||||
test(
|
||||
"://",
|
||||
`<p>://</p>`)
|
||||
test(
|
||||
"www",
|
||||
`<p>www</p>`)
|
||||
test(
|
||||
"ftps://gitea.com",
|
||||
`<p>ftps://gitea.com</p>`)
|
||||
|
||||
t.Run("LinkEllipsis", func(t *testing.T) {
|
||||
input := util.EllipsisDisplayString("http://10.1.2.3", 12)
|
||||
assert.Equal(t, "http://10…", input)
|
||||
test(input, "<p>http://10…</p>")
|
||||
|
||||
input = util.EllipsisDisplayString("http://10.1.2.3", 13)
|
||||
assert.Equal(t, "http://10.…", input)
|
||||
test(input, "<p>http://10.…</p>")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRender_email(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
res, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res), "input: %s", input)
|
||||
}
|
||||
|
||||
// Text that should be turned into email link
|
||||
test(
|
||||
"info@gitea.com",
|
||||
`<p><a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a></p>`)
|
||||
test(
|
||||
"(info@gitea.com)",
|
||||
`<p>(<a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>)</p>`)
|
||||
test(
|
||||
"[info@gitea.com]",
|
||||
`<p>[<a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>]</p>`)
|
||||
test(
|
||||
"info@gitea.com.",
|
||||
`<p><a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>.</p>`)
|
||||
test(
|
||||
"firstname+lastname@gitea.com",
|
||||
`<p><a href="mailto:firstname+lastname@gitea.com" rel="nofollow">firstname+lastname@gitea.com</a></p>`)
|
||||
test(
|
||||
"send email to info@gitea.co.uk.",
|
||||
`<p>send email to <a href="mailto:info@gitea.co.uk" rel="nofollow">info@gitea.co.uk</a>.</p>`)
|
||||
|
||||
test(
|
||||
`j.doe@example.com,
|
||||
j.doe@example.com.
|
||||
j.doe@example.com;
|
||||
j.doe@example.com?
|
||||
j.doe@example.com!`,
|
||||
`<p><a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>,
|
||||
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>.
|
||||
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>;
|
||||
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>?
|
||||
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>!</p>`)
|
||||
|
||||
// match GitHub behavior
|
||||
test("email@domain@domain.com", `<p>email@<a href="mailto:domain@domain.com" rel="nofollow">domain@domain.com</a></p>`)
|
||||
|
||||
// match GitHub behavior
|
||||
test(`"info@gitea.com"`, `<p>"<a href="mailto:info@gitea.com" rel="nofollow">info@gitea.com</a>"</p>`)
|
||||
|
||||
// Test that should *not* be turned into email links
|
||||
test(
|
||||
"/home/gitea/mailstore/info@gitea/com",
|
||||
`<p>/home/gitea/mailstore/info@gitea/com</p>`)
|
||||
test(
|
||||
"git@try.gitea.io:go-gitea/gitea.git",
|
||||
`<p>git@try.gitea.io:go-gitea/gitea.git</p>`)
|
||||
test(
|
||||
"https://foo:bar@gitea.io",
|
||||
`<p><a href="https://foo:bar@gitea.io" rel="nofollow">https://foo:bar@gitea.io</a></p>`)
|
||||
test(
|
||||
"gitea@3",
|
||||
`<p>gitea@3</p>`)
|
||||
test(
|
||||
"gitea@gmail.c",
|
||||
`<p>gitea@gmail.c</p>`)
|
||||
test(
|
||||
"email@domain..com",
|
||||
`<p>email@domain..com</p>`)
|
||||
|
||||
cases := []struct {
|
||||
input, expected string
|
||||
}{
|
||||
// match GitHub behavior
|
||||
{"?a@d.zz", `<p>?<a href="mailto:a@d.zz" rel="nofollow">a@d.zz</a></p>`},
|
||||
{"*a@d.zz", `<p>*<a href="mailto:a@d.zz" rel="nofollow">a@d.zz</a></p>`},
|
||||
{"~a@d.zz", `<p>~<a href="mailto:a@d.zz" rel="nofollow">a@d.zz</a></p>`},
|
||||
|
||||
// the following cases don't match GitHub behavior, but they are valid email addresses ...
|
||||
// maybe we should reduce the candidate characters for the "name" part in the future
|
||||
{"a*a@d.zz", `<p><a href="mailto:a*a@d.zz" rel="nofollow">a*a@d.zz</a></p>`},
|
||||
{"a~a@d.zz", `<p><a href="mailto:a~a@d.zz" rel="nofollow">a~a@d.zz</a></p>`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
test(c.input, c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_emoji(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
|
||||
|
||||
test := func(input, expected string) {
|
||||
expected = strings.ReplaceAll(expected, "&", "&")
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
// Make sure we can successfully match every emoji in our dataset with regex
|
||||
for i := range emoji.GemojiData {
|
||||
test(
|
||||
emoji.GemojiData[i].Emoji,
|
||||
`<p><span class="emoji" aria-label="`+emoji.GemojiData[i].Description+`">`+emoji.GemojiData[i].Emoji+`</span></p>`)
|
||||
}
|
||||
for i := range emoji.GemojiData {
|
||||
test(
|
||||
":"+emoji.GemojiData[i].Aliases[0]+":",
|
||||
`<p><span class="emoji" aria-label="`+emoji.GemojiData[i].Description+`">`+emoji.GemojiData[i].Emoji+`</span></p>`)
|
||||
}
|
||||
|
||||
// Text that should be turned into or recognized as emoji
|
||||
test(
|
||||
":gitea:",
|
||||
`<p><span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span></p>`)
|
||||
test(
|
||||
":custom-emoji:",
|
||||
`<p>:custom-emoji:</p>`)
|
||||
setting.UI.CustomEmojisMap["custom-emoji"] = ":custom-emoji:"
|
||||
test(
|
||||
":custom-emoji:",
|
||||
`<p><span class="emoji" aria-label="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span></p>`)
|
||||
test(
|
||||
"这是字符:1::+1: some🐊 \U0001f44d:custom-emoji: :gitea:",
|
||||
`<p>这是字符:1:<span class="emoji" aria-label="thumbs up">👍</span> some<span class="emoji" aria-label="crocodile">🐊</span> `+
|
||||
`<span class="emoji" aria-label="thumbs up">👍</span><span class="emoji" aria-label="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span> `+
|
||||
`<span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span></p>`)
|
||||
test(
|
||||
"Some text with 😄 in the middle",
|
||||
`<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle</p>`)
|
||||
test(
|
||||
"Some text with :smile: in the middle",
|
||||
`<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle</p>`)
|
||||
test(
|
||||
"Some text with 😄😄 2 emoji next to each other",
|
||||
`<p>Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span><span class="emoji" aria-label="grinning face with smiling eyes">😄</span> 2 emoji next to each other</p>`)
|
||||
test(
|
||||
"😎🤪🔐🤑❓",
|
||||
`<p><span class="emoji" aria-label="smiling face with sunglasses">😎</span><span class="emoji" aria-label="zany face">🤪</span><span class="emoji" aria-label="locked with key">🔐</span><span class="emoji" aria-label="money-mouth face">🤑</span><span class="emoji" aria-label="red question mark">❓</span></p>`)
|
||||
|
||||
// should match nothing
|
||||
test(":100:200", `<p>:100:200</p>`)
|
||||
test("std::thread::something", `<p>std::thread::something</p>`)
|
||||
test(":not exist:", `<p>:not exist:</p>`)
|
||||
}
|
||||
|
||||
func TestRender_ShortLinks(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
tree := markup.TestRepoURL + "src/master"
|
||||
|
||||
test := func(input, expected string) {
|
||||
buffer, err := markdown.RenderString(markup.NewTestRenderContext(tree), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
|
||||
}
|
||||
|
||||
url := tree + "/Link"
|
||||
otherURL := tree + "/Other-Link"
|
||||
encodedURL := tree + "/Link%3F"
|
||||
imgurl := tree + "/Link.jpg"
|
||||
otherImgurl := tree + "/Link+Other.jpg"
|
||||
encodedImgurl := tree + "/Link+%23.jpg"
|
||||
notencodedImgurl := tree + "/some/path/Link%20#.jpg"
|
||||
renderableFileURL := tree + "/markdown_file.md"
|
||||
unrenderableFileURL := tree + "/file.zip"
|
||||
favicon := "http://google.com/favicon.ico"
|
||||
|
||||
test(
|
||||
"[[Link]]",
|
||||
`<p><a href="`+url+`" rel="nofollow">Link</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link.-]]",
|
||||
`<p><a href="http://localhost:3000/test-owner/test-repo/src/master/Link.-" rel="nofollow">Link.-</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link.jpg]]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Link.jpg" alt="Link.jpg"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[["+favicon+"]]",
|
||||
`<p><a href="`+favicon+`" rel="nofollow"><img src="`+favicon+`" title="favicon.ico" alt="`+favicon+`"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link]]",
|
||||
`<p><a href="`+url+`" rel="nofollow">Name</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link.jpg]]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Name" alt="Name"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link.jpg|alt=AltName]]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="AltName" alt="AltName"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link.jpg|title=Title]]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="Title"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link.jpg|alt=AltName|title=Title]]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="AltName"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link.jpg|alt=\"AltName\"|title='Title']]",
|
||||
`<p><a href="`+imgurl+`" rel="nofollow"><img src="`+imgurl+`" title="Title" alt="AltName"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link Other.jpg|alt=\"AltName\"|title='Title']]",
|
||||
`<p><a href="`+otherImgurl+`" rel="nofollow"><img src="`+otherImgurl+`" title="Title" alt="AltName"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link]] [[Other Link]]",
|
||||
`<p><a href="`+url+`" rel="nofollow">Link</a> <a href="`+otherURL+`" rel="nofollow">Other Link</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link?]]",
|
||||
`<p><a href="`+encodedURL+`" rel="nofollow">Link?</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link]] [[Other Link]] [[Link?]]",
|
||||
`<p><a href="`+url+`" rel="nofollow">Link</a> <a href="`+otherURL+`" rel="nofollow">Other Link</a> <a href="`+encodedURL+`" rel="nofollow">Link?</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[markdown_file.md]]",
|
||||
`<p><a href="`+renderableFileURL+`" rel="nofollow">markdown_file.md</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[file.zip]]",
|
||||
`<p><a href="`+unrenderableFileURL+`" rel="nofollow">file.zip</a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Link #.jpg]]",
|
||||
`<p><a href="`+encodedImgurl+`" rel="nofollow"><img src="`+encodedImgurl+`" title="Link #.jpg" alt="Link #.jpg"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"[[Name|Link #.jpg|alt=\"AltName\"|title='Title']]",
|
||||
`<p><a href="`+encodedImgurl+`" rel="nofollow"><img src="`+encodedImgurl+`" title="Title" alt="AltName"/></a></p>`,
|
||||
)
|
||||
// FIXME: it's unable to resolve: [[link?k=v]]
|
||||
// FIXME: it is a wrong test case, it is not an image, but a link with anchor "#.jpg"
|
||||
test(
|
||||
"[[some/path/Link #.jpg]]",
|
||||
`<p><a href="`+notencodedImgurl+`" rel="nofollow"><img src="`+notencodedImgurl+`" title="Link #.jpg" alt="some/path/Link #.jpg"/></a></p>`,
|
||||
)
|
||||
test(
|
||||
"<p><a href=\"https://example.org\">[[foobar]]</a></p>",
|
||||
`<p><a href="https://example.org" rel="nofollow">[[foobar]]</a></p>`,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ParseClusterFuzz(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
|
||||
localMetas := map[string]string{"user": "go-gitea", "repo": "gitea"}
|
||||
|
||||
data := "<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
|
||||
|
||||
var res strings.Builder
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, res.String(), "<html")
|
||||
|
||||
data = "<!DOCTYPE html>\n<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
|
||||
|
||||
res.Reset()
|
||||
err = markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, res.String(), "<html")
|
||||
}
|
||||
|
||||
func TestPostProcess(t *testing.T) {
|
||||
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
test := func(input, expected string) {
|
||||
var res strings.Builder
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(markup.TestAppURL, map[string]string{"user": "go-gitea", "repo": "gitea"}), strings.NewReader(input), &res)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
|
||||
}
|
||||
|
||||
// Issue index shouldn't be post-processing in a document.
|
||||
test(
|
||||
"#1",
|
||||
"#1")
|
||||
|
||||
// But cross-referenced issue index should work.
|
||||
test(
|
||||
"go-gitea/gitea#12345",
|
||||
`<a href="/go-gitea/gitea/issues/12345" class="ref-issue">go-gitea/gitea#12345</a>`)
|
||||
|
||||
// Test that other post-processing still works.
|
||||
test(
|
||||
":gitea:",
|
||||
`<span class="emoji" aria-label="gitea"><img alt=":gitea:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/gitea.png"/></span>`)
|
||||
test(
|
||||
"Some text with 😄 in the middle",
|
||||
`Some text with <span class="emoji" aria-label="grinning face with smiling eyes">😄</span> in the middle`)
|
||||
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
|
||||
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
|
||||
|
||||
// special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible
|
||||
test("<script>a", `<script>a`)
|
||||
test("<script>a</script>", `<script>a</script>`)
|
||||
test("<STYLE>a", `<STYLE>a`)
|
||||
test("<style>a</STYLE>", `<style>a</STYLE>`)
|
||||
|
||||
// other special tags, our special behavior
|
||||
test("<?php\nfoo", "<?php\nfoo")
|
||||
test("<%asp\nfoo", "<%asp\nfoo")
|
||||
}
|
||||
|
||||
func TestIssue16020(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
|
||||
localMetas := map[string]string{
|
||||
"user": "go-gitea",
|
||||
"repo": "gitea",
|
||||
}
|
||||
|
||||
data := `<img src="data:image/png;base64,i//V"/>`
|
||||
|
||||
var res strings.Builder
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, data, res.String())
|
||||
}
|
||||
|
||||
func BenchmarkEmojiPostprocess(b *testing.B) {
|
||||
data := "🥰 "
|
||||
for len(data) < 1<<16 {
|
||||
data += data
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
var res strings.Builder
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res)
|
||||
assert.NoError(b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzz(t *testing.T) {
|
||||
s := "t/l/issues/8#/../../a"
|
||||
renderContext := markup.NewTestRenderContext()
|
||||
err := markup.PostProcessDefault(renderContext, strings.NewReader(s), io.Discard)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIssue18471(t *testing.T) {
|
||||
defer testModule.MockVariableValue(&setting.AppURL, markup.TestAppURL)()
|
||||
|
||||
data := markup.TestAppURL + `org/repo/compare/783b039...da951ce`
|
||||
|
||||
var res strings.Builder
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, res.String())
|
||||
}
|
||||
|
||||
func TestIsFullURL(t *testing.T) {
|
||||
assert.True(t, markup.IsFullURLString("https://example.com"))
|
||||
assert.True(t, markup.IsFullURLString("mailto:test@example.com"))
|
||||
assert.True(t, markup.IsFullURLString("data:image/11111"))
|
||||
assert.False(t, markup.IsFullURLString("/foo:bar"))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestToCWithHTML(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
t1 := `tag <a href="link">link</a> and <b>Bold</b>`
|
||||
t2 := "code block `<a>`"
|
||||
t3 := "markdown **bold**"
|
||||
input := `---
|
||||
include_toc: true
|
||||
---
|
||||
|
||||
# ` + t1 + `
|
||||
## ` + t2 + `
|
||||
#### ` + t3 + `
|
||||
## last
|
||||
`
|
||||
|
||||
renderCtx := markup.NewTestRenderContext().WithEnableHeadingIDGeneration(true)
|
||||
resultHTML, err := markdown.RenderString(renderCtx, input)
|
||||
assert.NoError(t, err)
|
||||
result := string(resultHTML)
|
||||
re := regexp.MustCompile(`(?s)<details class="frontmatter-content">.*?</details>`)
|
||||
result = re.ReplaceAllString(result, "\n")
|
||||
expected := `<details><summary>toc</summary>
|
||||
<ul>
|
||||
<li><a href="#user-content-tag-link-and-bold" rel="nofollow">tag link and Bold</a></li>
|
||||
<ul>
|
||||
<li><a href="#user-content-code-block-a" rel="nofollow">code block <a></a></li>
|
||||
<ul>
|
||||
<ul>
|
||||
<li><a href="#user-content-markdown-bold" rel="nofollow">markdown bold</a></li>
|
||||
</ul>
|
||||
</ul>
|
||||
<li><a href="#user-content-last" rel="nofollow">last</a></li>
|
||||
</ul>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<h1 id="user-content-tag-link-and-bold">tag <a href="/link" rel="nofollow">link</a> and <b>Bold</b></h1>
|
||||
<h2 id="user-content-code-block-a">code block <code><a></code></h2>
|
||||
<h4 id="user-content-markdown-bold">markdown <strong>bold</strong></h4>
|
||||
<h2 id="user-content-last">last</h2>
|
||||
`
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"io"
|
||||
)
|
||||
|
||||
type finalProcessor struct {
|
||||
renderInternal *RenderInternal
|
||||
extraHeadHTML template.HTML
|
||||
|
||||
output io.Writer
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (p *finalProcessor) Write(data []byte) (int, error) {
|
||||
p.buf.Write(data)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (p *finalProcessor) Close() error {
|
||||
// TODO: reading the whole markdown isn't a problem at the moment,
|
||||
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
|
||||
buf := p.buf.Bytes()
|
||||
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
|
||||
|
||||
tmp := bytes.TrimSpace(buf)
|
||||
isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(`</`)) > 0
|
||||
if !isLikelyHTML {
|
||||
// not HTML, write back directly
|
||||
_, err := p.output.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// add our extra head HTML into output
|
||||
headBytes := []byte("<head>")
|
||||
posHead := bytes.Index(buf, headBytes)
|
||||
var part1, part2 []byte
|
||||
if posHead >= 0 {
|
||||
part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):]
|
||||
} else {
|
||||
part1, part2 = nil, buf
|
||||
}
|
||||
if len(part1) > 0 {
|
||||
if _, err := p.output.Write(part1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.output.Write(part2)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderInternalAttrs(t *testing.T) {
|
||||
cases := []struct {
|
||||
input, protected, recovered string
|
||||
}{
|
||||
{
|
||||
input: `<div class="test">class="content"</div>`,
|
||||
protected: `<div data-attr-class="sec:test">class="content"</div>`,
|
||||
recovered: `<div class="test">class="content"</div>`,
|
||||
},
|
||||
{
|
||||
input: "<div\nclass=\"test\" data-xxx></div>",
|
||||
protected: `<div data-attr-class="sec:test" data-xxx></div>`,
|
||||
recovered: `<div class="test" data-xxx></div>`,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var r RenderInternal
|
||||
out := &bytes.Buffer{}
|
||||
in := r.init("sec", out, "")
|
||||
protected := r.ProtectSafeAttrs(template.HTML(c.input))
|
||||
assert.EqualValues(t, c.protected, protected)
|
||||
_, _ = io.WriteString(in, string(protected))
|
||||
_ = in.Close()
|
||||
assert.Equal(t, c.recovered, out.String())
|
||||
}
|
||||
|
||||
var r1, r2 RenderInternal
|
||||
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
|
||||
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
|
||||
_ = r1.init("sec", nil, "")
|
||||
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
|
||||
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
|
||||
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
|
||||
assert.Equal(t, "sec:val", r1.SafeValue("val"))
|
||||
recovered, ok := r1.RecoverProtectedValue("sec:val")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "val", recovered)
|
||||
recovered, ok = r1.RecoverProtectedValue("other:val")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, recovered)
|
||||
|
||||
out2 := &bytes.Buffer{}
|
||||
in2 := r2.init("sec-other", out2, "")
|
||||
_, _ = io.WriteString(in2, string(protected))
|
||||
_ = in2.Close()
|
||||
assert.Equal(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
|
||||
}
|
||||
|
||||
func TestRenderInternalExtraHead(t *testing.T) {
|
||||
t.Run("HeadExists", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
var r RenderInternal
|
||||
in := r.init("sec", out, `<MY-TAG>`)
|
||||
_, _ = io.WriteString(in, `<head>any</head>`)
|
||||
_ = in.Close()
|
||||
assert.Equal(t, `<head><MY-TAG>any</head>`, out.String())
|
||||
})
|
||||
|
||||
t.Run("HeadNotExists", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
var r RenderInternal
|
||||
in := r.init("sec", out, `<MY-TAG>`)
|
||||
_, _ = io.WriteString(in, `<div></div>`)
|
||||
_ = in.Close()
|
||||
assert.Equal(t, `<MY-TAG><div></div>`, out.String())
|
||||
})
|
||||
|
||||
t.Run("NotHTML", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
var r RenderInternal
|
||||
in := r.init("sec", out, `<MY-TAG>`)
|
||||
_, _ = io.WriteString(in, `<any>`)
|
||||
_ = in.Close()
|
||||
assert.Equal(t, `<any>`, out.String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"html/template"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var reAttrClass = sync.OnceValue(func() *regexp.Regexp {
|
||||
// TODO: it isn't a problem at the moment because our HTML contents are always well constructed
|
||||
return regexp.MustCompile(`(<[^>]+)\s+class="([^"]+)"([^>]*>)`)
|
||||
})
|
||||
|
||||
// RenderInternal also works without initialization
|
||||
// If no initialization (no secureID), it will not protect any attributes and return the original name&value
|
||||
type RenderInternal struct {
|
||||
secureID string
|
||||
secureIDPrefix string
|
||||
}
|
||||
|
||||
func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
|
||||
buf := make([]byte, 12)
|
||||
_, err := rand.Read(buf)
|
||||
if err != nil {
|
||||
panic("unable to generate secure id")
|
||||
}
|
||||
return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML)
|
||||
}
|
||||
|
||||
func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
|
||||
r.secureID = secID
|
||||
r.secureIDPrefix = r.secureID + ":"
|
||||
return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML}
|
||||
}
|
||||
|
||||
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {
|
||||
if !strings.HasPrefix(v, r.secureIDPrefix) {
|
||||
return "", false
|
||||
}
|
||||
return v[len(r.secureIDPrefix):], true
|
||||
}
|
||||
|
||||
func (r *RenderInternal) SafeAttr(name string) string {
|
||||
if r.secureID == "" {
|
||||
return name
|
||||
}
|
||||
return "data-attr-" + name
|
||||
}
|
||||
|
||||
func (r *RenderInternal) SafeValue(val string) string {
|
||||
if r.secureID == "" {
|
||||
return val
|
||||
}
|
||||
return r.secureID + ":" + val
|
||||
}
|
||||
|
||||
func (r *RenderInternal) NodeSafeAttr(attr, val string) html.Attribute {
|
||||
return html.Attribute{Key: r.SafeAttr(attr), Val: r.SafeValue(val)}
|
||||
}
|
||||
|
||||
func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
|
||||
if r.secureID == "" {
|
||||
return content
|
||||
}
|
||||
return template.HTML(reAttrClass().ReplaceAllString(string(content), `$1 data-attr-class="`+r.secureIDPrefix+`$2"$3`))
|
||||
}
|
||||
|
||||
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
|
||||
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
|
||||
_, err := io.WriteString(w, string(htmlStr))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
||||
markup.RefreshFileNamePatterns()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
)
|
||||
|
||||
// Details is a block that contains Summary and details
|
||||
type Details struct {
|
||||
ast.BaseBlock
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump .
|
||||
func (n *Details) Dump(source []byte, level int) {
|
||||
ast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
// KindDetails is the NodeKind for Details
|
||||
var KindDetails = ast.NewNodeKind("Details")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *Details) Kind() ast.NodeKind {
|
||||
return KindDetails
|
||||
}
|
||||
|
||||
// NewDetails returns a new Paragraph node.
|
||||
func NewDetails() *Details {
|
||||
return &Details{}
|
||||
}
|
||||
|
||||
// Summary is a block that contains the summary of details block
|
||||
type Summary struct {
|
||||
ast.BaseBlock
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump .
|
||||
func (n *Summary) Dump(source []byte, level int) {
|
||||
ast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
// KindSummary is the NodeKind for Summary
|
||||
var KindSummary = ast.NewNodeKind("Summary")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *Summary) Kind() ast.NodeKind {
|
||||
return KindSummary
|
||||
}
|
||||
|
||||
// NewSummary returns a new Summary node.
|
||||
func NewSummary() *Summary {
|
||||
return &Summary{}
|
||||
}
|
||||
|
||||
// TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
|
||||
type TaskCheckBoxListItem struct {
|
||||
*ast.ListItem
|
||||
IsChecked bool
|
||||
SourcePosition int
|
||||
}
|
||||
|
||||
// KindTaskCheckBoxListItem is the NodeKind for TaskCheckBoxListItem
|
||||
var KindTaskCheckBoxListItem = ast.NewNodeKind("TaskCheckBoxListItem")
|
||||
|
||||
// Dump implements Node.Dump .
|
||||
func (n *TaskCheckBoxListItem) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["IsChecked"] = strconv.FormatBool(n.IsChecked)
|
||||
m["SourcePosition"] = strconv.FormatInt(int64(n.SourcePosition), 10)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *TaskCheckBoxListItem) Kind() ast.NodeKind {
|
||||
return KindTaskCheckBoxListItem
|
||||
}
|
||||
|
||||
// NewTaskCheckBoxListItem returns a new TaskCheckBoxListItem node.
|
||||
func NewTaskCheckBoxListItem(listItem *ast.ListItem) *TaskCheckBoxListItem {
|
||||
return &TaskCheckBoxListItem{
|
||||
ListItem: listItem,
|
||||
}
|
||||
}
|
||||
|
||||
// Icon is an inline for a Fomantic UI icon
|
||||
type Icon struct {
|
||||
ast.BaseInline
|
||||
Name []byte
|
||||
}
|
||||
|
||||
// ColorPreview is an inline for a color preview
|
||||
type ColorPreview struct {
|
||||
ast.BaseInline
|
||||
Color []byte
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *ColorPreview) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Color"] = string(n.Color)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindColorPreview is the NodeKind for ColorPreview
|
||||
var KindColorPreview = ast.NewNodeKind("ColorPreview")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *ColorPreview) Kind() ast.NodeKind {
|
||||
return KindColorPreview
|
||||
}
|
||||
|
||||
// NewColorPreview returns a new Span node.
|
||||
func NewColorPreview(color []byte) *ColorPreview {
|
||||
return &ColorPreview{
|
||||
BaseInline: ast.BaseInline{},
|
||||
Color: color,
|
||||
}
|
||||
}
|
||||
|
||||
// Attention is an inline for an attention
|
||||
type Attention struct {
|
||||
ast.BaseInline
|
||||
AttentionType string
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *Attention) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["AttentionType"] = n.AttentionType
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindAttention is the NodeKind for Attention
|
||||
var KindAttention = ast.NewNodeKind("Attention")
|
||||
|
||||
// Kind implements Node.Kind.
|
||||
func (n *Attention) Kind() ast.NodeKind {
|
||||
return KindAttention
|
||||
}
|
||||
|
||||
// NewAttention returns a new Attention node.
|
||||
func NewAttention(attentionType string) *Attention {
|
||||
return &Attention{
|
||||
BaseInline: ast.BaseInline{},
|
||||
AttentionType: attentionType,
|
||||
}
|
||||
}
|
||||
|
||||
var KindRawHTML = ast.NewNodeKind("RawHTML")
|
||||
|
||||
type RawHTML struct {
|
||||
ast.BaseBlock
|
||||
rawHTML template.HTML
|
||||
}
|
||||
|
||||
func (n *RawHTML) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["RawHTML"] = string(n.rawHTML)
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
func (n *RawHTML) Kind() ast.NodeKind {
|
||||
return KindRawHTML
|
||||
}
|
||||
|
||||
func NewRawHTML(rawHTML template.HTML) *RawHTML {
|
||||
return &RawHTML{rawHTML: rawHTML}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func nodeToTable(meta *yaml.Node) ast.Node {
|
||||
for meta != nil && meta.Kind == yaml.DocumentNode {
|
||||
meta = meta.Content[0]
|
||||
}
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
switch meta.Kind {
|
||||
case yaml.MappingNode:
|
||||
return mappingNodeToTable(meta)
|
||||
case yaml.SequenceNode:
|
||||
return sequenceNodeToTable(meta)
|
||||
default:
|
||||
return ast.NewString([]byte(meta.Value))
|
||||
}
|
||||
}
|
||||
|
||||
func mappingNodeToTable(meta *yaml.Node) ast.Node {
|
||||
table := east.NewTable()
|
||||
alignments := make([]east.Alignment, 0, len(meta.Content)/2)
|
||||
for i := 0; i < len(meta.Content); i += 2 {
|
||||
alignments = append(alignments, east.AlignNone)
|
||||
}
|
||||
|
||||
headerRow := east.NewTableRow(alignments)
|
||||
valueRow := east.NewTableRow(alignments)
|
||||
for i := 0; i < len(meta.Content); i += 2 {
|
||||
cell := east.NewTableCell()
|
||||
|
||||
cell.AppendChild(cell, nodeToTable(meta.Content[i]))
|
||||
headerRow.AppendChild(headerRow, cell)
|
||||
|
||||
if i+1 < len(meta.Content) {
|
||||
cell = east.NewTableCell()
|
||||
cell.AppendChild(cell, nodeToTable(meta.Content[i+1]))
|
||||
valueRow.AppendChild(valueRow, cell)
|
||||
}
|
||||
}
|
||||
|
||||
table.AppendChild(table, east.NewTableHeader(headerRow))
|
||||
table.AppendChild(table, valueRow)
|
||||
return table
|
||||
}
|
||||
|
||||
func sequenceNodeToTable(meta *yaml.Node) ast.Node {
|
||||
table := east.NewTable()
|
||||
alignments := []east.Alignment{east.AlignNone}
|
||||
for _, item := range meta.Content {
|
||||
row := east.NewTableRow(alignments)
|
||||
cell := east.NewTableCell()
|
||||
cell.AppendChild(cell, nodeToTable(item))
|
||||
row.AppendChild(row, cell)
|
||||
table.AppendChild(table, row)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func nodeToDetails(g *ASTTransformer, meta *yaml.Node) ast.Node {
|
||||
for meta != nil && meta.Kind == yaml.DocumentNode {
|
||||
meta = meta.Content[0]
|
||||
}
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
if meta.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
for i := 0; i < len(meta.Content); i += 2 {
|
||||
if meta.Content[i].Kind == yaml.ScalarNode {
|
||||
keys = append(keys, meta.Content[i].Value)
|
||||
}
|
||||
}
|
||||
details := NewDetails()
|
||||
details.SetAttributeString(g.renderInternal.SafeAttr("class"), g.renderInternal.SafeValue("frontmatter-content"))
|
||||
summary := NewSummary()
|
||||
summaryInnerHTML := htmlutil.HTMLFormat("%s %s", svg.RenderHTML("octicon-table", 12), strings.Join(keys, ", "))
|
||||
summary.AppendChild(summary, NewRawHTML(summaryInnerHTML))
|
||||
details.AppendChild(details, summary)
|
||||
details.AppendChild(details, nodeToTable(meta))
|
||||
return details
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// ASTTransformer is a default transformer of the goldmark tree.
|
||||
type ASTTransformer struct {
|
||||
renderInternal *internal.RenderInternal
|
||||
attentionTypes container.Set[string]
|
||||
}
|
||||
|
||||
func NewASTTransformer(renderInternal *internal.RenderInternal) *ASTTransformer {
|
||||
return &ASTTransformer{
|
||||
renderInternal: renderInternal,
|
||||
attentionTypes: container.SetOf("note", "tip", "important", "warning", "caution"),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) applyElementDir(n ast.Node) {
|
||||
if !markup.RenderBehaviorForTesting.DisableAdditionalAttributes {
|
||||
n.SetAttributeString("dir", "auto")
|
||||
}
|
||||
}
|
||||
|
||||
// Transform transforms the given AST tree.
|
||||
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
firstChild := node.FirstChild()
|
||||
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
|
||||
rc := pc.Get(renderConfigKey).(*RenderConfig)
|
||||
|
||||
tocMode := ""
|
||||
if rc.yamlNode != nil {
|
||||
metaNode := rc.toMetaNode(g)
|
||||
if metaNode != nil {
|
||||
node.InsertBefore(node, firstChild, metaNode)
|
||||
}
|
||||
tocMode = rc.TOC
|
||||
}
|
||||
|
||||
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
switch v := n.(type) {
|
||||
case *ast.Paragraph:
|
||||
g.applyElementDir(v)
|
||||
case *ast.List:
|
||||
g.transformList(ctx, v, rc)
|
||||
case *ast.Text:
|
||||
if v.SoftLineBreak() && !v.HardLineBreak() {
|
||||
newLineHardBreak := ctx.RenderOptions.Metas["markdownNewLineHardBreak"] == "true"
|
||||
v.SetHardLineBreak(newLineHardBreak)
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
g.transformCodeSpan(ctx, v, reader)
|
||||
case *ast.FencedCodeBlock:
|
||||
g.transformFencedCodeblock(v, reader)
|
||||
case *ast.Blockquote:
|
||||
return g.transformBlockquote(v, reader)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
|
||||
if ctx.RenderOptions.EnableHeadingIDGeneration {
|
||||
showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main"
|
||||
showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar
|
||||
switch {
|
||||
case showTocInMain:
|
||||
ctx.TocShowInSection = markup.TocShowInMain
|
||||
case showTocInSidebar:
|
||||
ctx.TocShowInSection = markup.TocShowInSidebar
|
||||
}
|
||||
}
|
||||
|
||||
if rc.Lang != "" {
|
||||
node.SetAttributeString("lang", []byte(rc.Lang))
|
||||
}
|
||||
}
|
||||
|
||||
// NewHTMLRenderer creates a HTMLRenderer to render in the gitea form.
|
||||
func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &HTMLRenderer{
|
||||
renderInternal: renderInternal,
|
||||
Config: html.NewConfig(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// HTMLRenderer is a renderer.NodeRenderer implementation that
|
||||
// renders gitea specific features.
|
||||
type HTMLRenderer struct {
|
||||
html.Config
|
||||
renderInternal *internal.RenderInternal
|
||||
}
|
||||
|
||||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
||||
func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(KindDetails, r.renderDetails)
|
||||
reg.Register(KindSummary, r.renderSummary)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(KindAttention, r.renderAttention)
|
||||
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
|
||||
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
|
||||
reg.Register(KindRawHTML, r.renderRawHTML)
|
||||
}
|
||||
|
||||
// renderCodeBlock wraps indented code blocks like the fenced renderer
|
||||
func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
opening := r.renderInternal.ProtectSafeAttrs(`<div class="code-block-container code-overflow-scroll"><pre class="code-block"><code>`)
|
||||
if _, err := w.WriteString(string(opening)); err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
lines := n.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
r.Writer.RawWrite(w, line.Value(source))
|
||||
}
|
||||
} else {
|
||||
if _, err := w.WriteString("</code></pre></div>"); err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Document)
|
||||
|
||||
if val, has := n.AttributeString("lang"); has {
|
||||
var err error
|
||||
if entering {
|
||||
_, err = w.WriteString("<div")
|
||||
if err == nil {
|
||||
_, err = fmt.Fprintf(w, ` lang=%q`, val)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = w.WriteRune('>')
|
||||
}
|
||||
} else {
|
||||
_, err = w.WriteString("</div>")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderDetails(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
var err error
|
||||
if entering {
|
||||
if _, err = w.WriteString("<details"); err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
html.RenderAttributes(w, node, nil)
|
||||
_, err = w.WriteString(">")
|
||||
} else {
|
||||
_, err = w.WriteString("</details>")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
var err error
|
||||
if entering {
|
||||
_, err = w.WriteString("<summary>")
|
||||
} else {
|
||||
_, err = w.WriteString("</summary>")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*RawHTML)
|
||||
_, err := w.WriteString(string(r.renderInternal.ProtectSafeAttrs(n.rawHTML)))
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/markup/markdown/math"
|
||||
"gitea.dev/modules/setting"
|
||||
giteautil "gitea.dev/modules/util"
|
||||
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
var (
|
||||
renderContextKey = parser.NewContextKey()
|
||||
renderConfigKey = parser.NewContextKey()
|
||||
)
|
||||
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
sum int64
|
||||
limit int64
|
||||
}
|
||||
|
||||
// Write implements the standard Write interface:
|
||||
func (l *limitWriter) Write(data []byte) (int, error) {
|
||||
leftToWrite := l.limit - l.sum
|
||||
if leftToWrite < int64(len(data)) {
|
||||
n, err := l.w.Write(data[:leftToWrite])
|
||||
l.sum += int64(n)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
return n, errors.New("rendered content too large - truncating render")
|
||||
}
|
||||
n, err := l.w.Write(data)
|
||||
l.sum += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// newParserContext creates a parser.Context with the render context set
|
||||
func newParserContext(ctx *markup.RenderContext) parser.Context {
|
||||
pc := parser.NewContext()
|
||||
pc.Set(renderContextKey, ctx)
|
||||
return pc
|
||||
}
|
||||
|
||||
type GlodmarkRender struct {
|
||||
ctx *markup.RenderContext
|
||||
|
||||
goldmarkMarkdown goldmark.Markdown
|
||||
}
|
||||
|
||||
func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
|
||||
return r.goldmarkMarkdown.Convert(source, writer, opts...)
|
||||
}
|
||||
|
||||
func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
|
||||
if entering {
|
||||
languageBytes, _ := c.Language()
|
||||
languageStr := giteautil.IfZero(string(languageBytes), "text")
|
||||
|
||||
preClasses := "code-block"
|
||||
if languageStr == "mermaid" || languageStr == "math" {
|
||||
preClasses += " is-loading"
|
||||
}
|
||||
|
||||
// include language-x class as part of commonmark spec, "chroma" class is used to highlight the code
|
||||
// the "display" class is used by "js/markup/math.ts" to render the code element as a block
|
||||
// the "math.ts" strictly depends on the structure: <pre class="code-block is-loading"><code class="language-math display">...</code></pre>
|
||||
err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<div class="code-block-container code-overflow-scroll"><pre class="%s"><code class="chroma language-%s display">`, preClasses, languageStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err := w.WriteString("</code></pre></div>")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type goldmarkEmphasisParser struct {
|
||||
parser.InlineParser
|
||||
}
|
||||
|
||||
func goldmarkNewEmphasisParser() parser.InlineParser {
|
||||
return &goldmarkEmphasisParser{parser.NewEmphasisParser()}
|
||||
}
|
||||
|
||||
func (s *goldmarkEmphasisParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
line, _ := block.PeekLine()
|
||||
if len(line) > 1 && line[0] == '_' {
|
||||
// a special trick to avoid parsing emphasis in filenames like "module/__init__.py"
|
||||
end := bytes.IndexByte(line[1:], '_')
|
||||
mark := bytes.Index(line, []byte("_.py"))
|
||||
// check whether the "end" matches "_.py" or "__.py"
|
||||
if mark != -1 && (end == mark || end == mark-1) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.InlineParser.Parse(parent, block, pc)
|
||||
}
|
||||
|
||||
func goldmarkDefaultParser() parser.Parser {
|
||||
return parser.NewParser(parser.WithBlockParsers(parser.DefaultBlockParsers()...),
|
||||
parser.WithInlineParsers([]util.PrioritizedValue{
|
||||
util.Prioritized(parser.NewCodeSpanParser(), 100),
|
||||
util.Prioritized(parser.NewLinkParser(), 200),
|
||||
util.Prioritized(parser.NewAutoLinkParser(), 300),
|
||||
util.Prioritized(parser.NewRawHTMLParser(), 400),
|
||||
util.Prioritized(goldmarkNewEmphasisParser(), 500),
|
||||
}...),
|
||||
parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...),
|
||||
)
|
||||
}
|
||||
|
||||
// SpecializedMarkdown sets up the Gitea specific markdown extensions
|
||||
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
|
||||
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
|
||||
// at the moment it is fast enough (see the benchmarks)
|
||||
r := &GlodmarkRender{ctx: ctx}
|
||||
r.goldmarkMarkdown = goldmark.New(
|
||||
goldmark.WithParser(goldmarkDefaultParser()),
|
||||
goldmark.WithExtensions(
|
||||
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
extension.DefinitionList,
|
||||
common.FootnoteExtension,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithFormatOptions(
|
||||
chromahtml.WithClasses(true),
|
||||
chromahtml.PreventSurroundingPre(true),
|
||||
),
|
||||
highlighting.WithWrapperRenderer(r.highlightingRenderer),
|
||||
),
|
||||
math.NewExtension(&ctx.RenderInternal, math.Options{
|
||||
Enabled: setting.Markdown.EnableMath,
|
||||
ParseInlineDollar: setting.Markdown.MathCodeBlockOptions.ParseInlineDollar,
|
||||
ParseInlineParentheses: setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses, // this is a bad syntax "\( ... \)", it conflicts with normal markdown escaping
|
||||
ParseBlockDollar: setting.Markdown.MathCodeBlockOptions.ParseBlockDollar,
|
||||
ParseBlockSquareBrackets: setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets, // this is a bad syntax "\[ ... \]", it conflicts with normal markdown escaping
|
||||
}),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAttribute(),
|
||||
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
|
||||
),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
// Override the original Tasklist renderer!
|
||||
r.goldmarkMarkdown.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// render calls goldmark render to convert Markdown to HTML
|
||||
// NOTE: The output of this method MUST get sanitized separately!!!
|
||||
func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
converter := SpecializedMarkdown(ctx)
|
||||
lw := &limitWriter{
|
||||
w: output,
|
||||
limit: setting.UI.MaxDisplayFileSize * 3,
|
||||
}
|
||||
|
||||
// FIXME: Don't read all to memory, but goldmark doesn't support
|
||||
buf, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
log.Error("Unable to ReadAll: %v", err)
|
||||
return err
|
||||
}
|
||||
buf = giteautil.NormalizeEOL(buf)
|
||||
|
||||
// FIXME: should we include a timeout to abort the renderer if it takes too long?
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Panic in markdown: %v\n%s", err, log.Stack(2))
|
||||
escapedHTML := template.HTMLEscapeString(giteautil.UnsafeBytesToString(buf))
|
||||
_, _ = output.Write(giteautil.UnsafeStringToBytes(escapedHTML))
|
||||
}()
|
||||
|
||||
pc := newParserContext(ctx)
|
||||
|
||||
// Preserve original length.
|
||||
bufWithMetadataLength := len(buf)
|
||||
|
||||
rc := &RenderConfig{Meta: markup.RenderMetaAsDetails}
|
||||
buf, _ = ExtractMetadataBytes(buf, rc)
|
||||
|
||||
metaLength := max(bufWithMetadataLength-len(buf), 0)
|
||||
rc.metaLength = metaLength
|
||||
|
||||
pc.Set(renderConfigKey, rc)
|
||||
|
||||
if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil {
|
||||
log.Error("Unable to render: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkupName describes markup's name
|
||||
var MarkupName = "markdown"
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(Renderer{})
|
||||
}
|
||||
|
||||
type Renderer struct{}
|
||||
|
||||
var _ markup.PostProcessRenderer = (*Renderer)(nil)
|
||||
|
||||
func (Renderer) Name() string {
|
||||
return MarkupName
|
||||
}
|
||||
|
||||
func (Renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (Renderer) FileNamePatterns() []string {
|
||||
return setting.Markdown.FileNamePatterns
|
||||
}
|
||||
|
||||
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{}
|
||||
}
|
||||
|
||||
func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
return render(ctx, input, output)
|
||||
}
|
||||
|
||||
// Render renders Markdown to HTML with all specific handling stuff.
|
||||
func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
ctx.RenderOptions.MarkupType = MarkupName
|
||||
return markup.Render(ctx, input, output)
|
||||
}
|
||||
|
||||
// RenderString renders Markdown string to HTML with all specific handling stuff and return string
|
||||
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
|
||||
var buf strings.Builder
|
||||
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
||||
log.Warn("Unable to RenderString: %v, content: %s", err, giteautil.TruncateRunes(content, 200))
|
||||
err = nil
|
||||
return htmlutil.EscapeString(content), err
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
// RenderRaw renders Markdown to HTML without handling special links.
|
||||
func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
rd, wr := io.Pipe()
|
||||
defer func() {
|
||||
_ = rd.Close()
|
||||
_ = wr.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := render(ctx, input, wr); err != nil {
|
||||
_ = wr.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
_ = wr.Close()
|
||||
}()
|
||||
|
||||
return markup.SanitizeReader(rd, "", output)
|
||||
}
|
||||
|
||||
// RenderRawString renders Markdown to HTML without handling special links and return string
|
||||
func RenderRawString(ctx *markup.RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := RenderRaw(ctx, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestAttention(t *testing.T) {
|
||||
defer svg.MockIcon("octicon-info")()
|
||||
defer svg.MockIcon("octicon-light-bulb")()
|
||||
defer svg.MockIcon("octicon-report")()
|
||||
defer svg.MockIcon("octicon-alert")()
|
||||
defer svg.MockIcon("octicon-stop")()
|
||||
|
||||
test := func(input, expected string) {
|
||||
result, err := markdown.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(result)))
|
||||
}
|
||||
renderAttention := func(attention, icon string) string {
|
||||
tmpl := `<blockquote class="attention-header attention-{attention}"><p><svg class="attention-icon attention-{attention} svg {icon}" width="16" height="16"></svg><strong class="attention-{attention}">{Attention}</strong></p>`
|
||||
tmpl = strings.ReplaceAll(tmpl, "{attention}", attention)
|
||||
tmpl = strings.ReplaceAll(tmpl, "{icon}", icon)
|
||||
tmpl = strings.ReplaceAll(tmpl, "{Attention}", cases.Title(language.English).String(attention))
|
||||
return tmpl
|
||||
}
|
||||
|
||||
test(`
|
||||
> [!NOTE]
|
||||
> text
|
||||
`, renderAttention("note", "octicon-info")+"\n<p>text</p>\n</blockquote>")
|
||||
|
||||
test(`> [!note]`, renderAttention("note", "octicon-info")+"\n</blockquote>")
|
||||
test(`> [!tip]`, renderAttention("tip", "octicon-light-bulb")+"\n</blockquote>")
|
||||
test(`> [!important]`, renderAttention("important", "octicon-report")+"\n</blockquote>")
|
||||
test(`> [!warning]`, renderAttention("warning", "octicon-alert")+"\n</blockquote>")
|
||||
test(`> [!caution]`, renderAttention("caution", "octicon-stop")+"\n</blockquote>")
|
||||
|
||||
// escaped by mdformat
|
||||
test(`> \[!NOTE\]`, renderAttention("note", "octicon-info")+"\n</blockquote>")
|
||||
|
||||
// legacy GitHub style
|
||||
test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n</blockquote>")
|
||||
|
||||
// edge case (it used to cause panic)
|
||||
test(">\ntext", "<blockquote>\n</blockquote>\n<p>text</p>")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
)
|
||||
|
||||
func BenchmarkSpecializedMarkdown(b *testing.B) {
|
||||
// 240856 4719 ns/op
|
||||
for b.Loop() {
|
||||
markdown.SpecializedMarkdown(&markup.RenderContext{})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarkdownRender(b *testing.B) {
|
||||
// 23202 50840 ns/op
|
||||
for b.Loop() {
|
||||
_, _ = markdown.RenderString(markup.NewTestRenderContext(), "https://example.com\n- a\n- b\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const nl = "\n"
|
||||
|
||||
func TestMathRender(t *testing.T) {
|
||||
setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{ParseInlineDollar: true, ParseInlineParentheses: true}
|
||||
testcases := []struct {
|
||||
testcase string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"$a$",
|
||||
`<p><code class="language-math">a</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$ a $",
|
||||
`<p><code class="language-math">a</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$a$$b$",
|
||||
`<p><code class="language-math">a</code><code class="language-math">b</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$a$ $b$",
|
||||
`<p><code class="language-math">a</code> <code class="language-math">b</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
`\(a\) \(b\)`,
|
||||
`<p><code class="language-math">a</code> <code class="language-math">b</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
`$a$.`,
|
||||
`<p><code class="language-math">a</code>.</p>` + nl,
|
||||
},
|
||||
{
|
||||
`.$a$`,
|
||||
`<p>.$a$</p>` + nl,
|
||||
},
|
||||
{
|
||||
`$a a$b b$`,
|
||||
`<p>$a a$b b$</p>` + nl,
|
||||
},
|
||||
{
|
||||
`a a$b b`,
|
||||
`<p>a a$b b</p>` + nl,
|
||||
},
|
||||
{
|
||||
`a$b $a a$b b$`,
|
||||
`<p>a$b $a a$b b$</p>` + nl,
|
||||
},
|
||||
{
|
||||
"a$x$", // Pattern: "word$other$" The real world example is: "Price is between US$1 and US$2.", so don't parse this.
|
||||
`<p>a$x$</p>` + nl,
|
||||
},
|
||||
{
|
||||
"$x$a",
|
||||
`<p>$x$a</p>` + nl,
|
||||
},
|
||||
{
|
||||
"$a$ ($b$) [$c$] {$d$}",
|
||||
`<p><code class="language-math">a</code> (<code class="language-math">b</code>) [$c$] {$d$}</p>` + nl,
|
||||
},
|
||||
{
|
||||
"[$a$](link)",
|
||||
`<p><a href="/link" rel="nofollow"><code class="language-math">a</code></a></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$$a$$",
|
||||
`<p><code class="language-math">a</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$$a$$ test",
|
||||
`<p><code class="language-math">a</code> test</p>` + nl,
|
||||
},
|
||||
{
|
||||
"test $$a$$",
|
||||
`<p>test <code class="language-math">a</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
`foo $x=\$$ bar`,
|
||||
`<p>foo <code class="language-math">x=\$</code> bar</p>` + nl,
|
||||
},
|
||||
{
|
||||
`$\text{$b$}$`,
|
||||
`<p><code class="language-math">\text{$b$}</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"a$`b`$c",
|
||||
`<p>a<code class="language-math">b</code>c</p>` + nl,
|
||||
},
|
||||
{
|
||||
"a $`b`$ c",
|
||||
`<p>a <code class="language-math">b</code> c</p>` + nl,
|
||||
},
|
||||
{
|
||||
"a$``b``$c x$```y```$z",
|
||||
`<p>a<code class="language-math">b</code>c x<code class="language-math">y</code>z</p>` + nl,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.testcase, func(t *testing.T) {
|
||||
res, err := RenderString(markup.NewTestRenderContext(), test.testcase)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.expected, string(res))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMathRenderBlockIndent(t *testing.T) {
|
||||
setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{ParseBlockDollar: true, ParseBlockSquareBrackets: true}
|
||||
testcases := []struct {
|
||||
name string
|
||||
testcase string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"indent-0",
|
||||
`
|
||||
\[
|
||||
\alpha
|
||||
\]
|
||||
`,
|
||||
`<pre class="code-block is-loading"><code class="language-math display">
|
||||
\alpha
|
||||
</code></pre>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"indent-1",
|
||||
`
|
||||
\[
|
||||
\alpha
|
||||
\]
|
||||
`,
|
||||
`<pre class="code-block is-loading"><code class="language-math display">
|
||||
\alpha
|
||||
</code></pre>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"indent-2-mismatch",
|
||||
`
|
||||
\[
|
||||
a
|
||||
b
|
||||
c
|
||||
d
|
||||
\]
|
||||
`,
|
||||
`<pre class="code-block is-loading"><code class="language-math display">
|
||||
a
|
||||
b
|
||||
c
|
||||
d
|
||||
</code></pre>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"indent-2",
|
||||
`
|
||||
\[
|
||||
a
|
||||
b
|
||||
c
|
||||
\]
|
||||
`,
|
||||
`<pre class="code-block is-loading"><code class="language-math display">
|
||||
a
|
||||
b
|
||||
c
|
||||
</code></pre>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"indent-0-oneline",
|
||||
`$$ x $$
|
||||
foo`,
|
||||
`<code class="language-math display"> x </code>
|
||||
<p>foo</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"indent-3-oneline",
|
||||
` $$ x $$<SPACE>
|
||||
foo`,
|
||||
`<code class="language-math display"> x </code>
|
||||
<p>foo</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"quote-block",
|
||||
`
|
||||
> \[
|
||||
> a
|
||||
> \]
|
||||
> \[
|
||||
> b
|
||||
> \]
|
||||
`,
|
||||
`<blockquote>
|
||||
<pre class="code-block is-loading"><code class="language-math display">
|
||||
a
|
||||
</code></pre>
|
||||
<pre class="code-block is-loading"><code class="language-math display">
|
||||
b
|
||||
</code></pre>
|
||||
</blockquote>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"list-block",
|
||||
`
|
||||
1. a
|
||||
\[
|
||||
x
|
||||
\]
|
||||
2. b`,
|
||||
`<ol>
|
||||
<li>a
|
||||
<pre class="code-block is-loading"><code class="language-math display">
|
||||
x
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>b</li>
|
||||
</ol>
|
||||
`,
|
||||
},
|
||||
{
|
||||
"inline-non-math",
|
||||
`\[x]`,
|
||||
`<p>[x]</p>` + nl,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
res, err := RenderString(markup.NewTestRenderContext(), strings.ReplaceAll(test.testcase, "<SPACE>", " "))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.expected, string(res), "unexpected result for test case:\n%s", test.testcase)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMathRenderOptions(t *testing.T) {
|
||||
setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{}
|
||||
defer test.MockVariableValue(&setting.Markdown.MathCodeBlockOptions)
|
||||
test := func(t *testing.T, expected, input string) {
|
||||
res, err := RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(res)), "input: %s", input)
|
||||
}
|
||||
|
||||
// default (non-conflict) inline syntax
|
||||
test(t, `<p><code class="language-math">a</code></p>`, "$`a`$")
|
||||
|
||||
// ParseInlineDollar
|
||||
test(t, `<p>$a$</p>`, `$a$`)
|
||||
setting.Markdown.MathCodeBlockOptions.ParseInlineDollar = true
|
||||
test(t, `<p><code class="language-math">a</code></p>`, `$a$`)
|
||||
|
||||
// ParseInlineParentheses
|
||||
test(t, `<p>(a)</p>`, `\(a\)`)
|
||||
setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses = true
|
||||
test(t, `<p><code class="language-math">a</code></p>`, `\(a\)`)
|
||||
|
||||
// ParseBlockDollar
|
||||
test(t, `<p>$$
|
||||
a
|
||||
$$</p>
|
||||
`, `
|
||||
$$
|
||||
a
|
||||
$$
|
||||
`)
|
||||
setting.Markdown.MathCodeBlockOptions.ParseBlockDollar = true
|
||||
test(t, `<pre class="code-block is-loading"><code class="language-math display">
|
||||
a
|
||||
</code></pre>
|
||||
`, `
|
||||
$$
|
||||
a
|
||||
$$
|
||||
`)
|
||||
|
||||
// ParseBlockSquareBrackets
|
||||
test(t, `<p>[
|
||||
a
|
||||
]</p>
|
||||
`, `
|
||||
\[
|
||||
a
|
||||
\]
|
||||
`)
|
||||
setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets = true
|
||||
test(t, `<pre class="code-block is-loading"><code class="language-math display">
|
||||
a
|
||||
</code></pre>
|
||||
`, `
|
||||
\[
|
||||
a
|
||||
\]
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
AppURL = "http://localhost:3000/"
|
||||
testRepoOwnerName = "user13"
|
||||
testRepoName = "repo11"
|
||||
)
|
||||
|
||||
// these values should match the const above
|
||||
var localMetas = map[string]string{
|
||||
"user": testRepoOwnerName,
|
||||
"repo": testRepoName,
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
|
||||
}
|
||||
|
||||
googleRendered := `<p><a href="https://google.com/" rel="nofollow">https://google.com/</a></p>`
|
||||
test("<https://google.com/>", googleRendered)
|
||||
test("[Link](Link)", `<p><a href="/Link" rel="nofollow">Link</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Images(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
|
||||
const baseLink = "http://localhost:3000/user13/repo11"
|
||||
render := func(input, expected string) {
|
||||
buffer, err := markdown.RenderString(markup.NewTestRenderContext(baseLink), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
|
||||
}
|
||||
|
||||
url := "../../.images/src/02/train.jpg"
|
||||
title := "Train"
|
||||
href := "https://gitea.io"
|
||||
result := baseLink + "/.images/src/02/train.jpg" // resolved link should not go out of the base link
|
||||
// hint: With Markdown v2.5.2, there is a new syntax: [link](URL){:target="_blank"} , but we do not support it now
|
||||
|
||||
render(
|
||||
"",
|
||||
`<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
|
||||
|
||||
render(
|
||||
"[["+title+"|"+url+"]]",
|
||||
`<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
|
||||
render(
|
||||
"[]("+href+")",
|
||||
`<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
|
||||
|
||||
render(
|
||||
"",
|
||||
`<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`)
|
||||
|
||||
render(
|
||||
"[["+title+"|"+url+"]]",
|
||||
`<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`)
|
||||
render(
|
||||
"[]("+href+")",
|
||||
`<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
|
||||
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, false)()
|
||||
render(
|
||||
"<a><img src='a.jpg'></a>", // by the way, empty "a" tag will be removed
|
||||
`<p dir="auto"><img src="http://localhost:3000/user13/repo11/a.jpg" loading="lazy"/></p>`)
|
||||
}
|
||||
|
||||
func TestTotal_RenderString(t *testing.T) {
|
||||
const FullURL = AppURL + testRepoOwnerName + "/" + testRepoName + "/"
|
||||
setting.AppURL = AppURL
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
// Test cases without ambiguous links (It is not right to copy a whole file here, instead it should clearly test what is being tested)
|
||||
sameCases := []string{
|
||||
// dear imgui wiki markdown extract: special wiki syntax
|
||||
`Wiki! Enjoy :)
|
||||
- [[Links, Language bindings, Engine bindings|Links]]
|
||||
- [[Tips]]
|
||||
|
||||
See commit 65f1bf27bc
|
||||
|
||||
Ideas and codes
|
||||
|
||||
- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786
|
||||
- Bezier widget (by @r-lyeh) ` + FullURL + `issues/786
|
||||
- Node graph editors https://github.com/ocornut/imgui/issues/306
|
||||
- [[Memory Editor|memory_editor_example]]
|
||||
- [[Plot var helper|plot_var_example]]`,
|
||||
// wine-staging wiki home extract: tables, special wiki syntax, images
|
||||
`## What is Wine Staging?
|
||||
**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
|
||||
|
||||
## Quick Links
|
||||
Here are some links to the most important topics. You can find the full list of pages at the sidebar.
|
||||
|
||||
| [[images/icon-install.png]] | [[Installation]] |
|
||||
|--------------------------------|----------------------------------------------------------|
|
||||
| [[images/icon-usage.png]] | [[Usage]] |
|
||||
`,
|
||||
// libgdx wiki page: inline images with special syntax
|
||||
`[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X.
|
||||
|
||||
1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop)
|
||||
[[images/1.png]]
|
||||
2. Perform a test run by hitting the Run! button.
|
||||
[[images/2.png]]
|
||||
|
||||
## More tests {#custom-id}
|
||||
|
||||
(from https://www.markdownguide.org/extended-syntax/)
|
||||
|
||||
### Checkboxes
|
||||
|
||||
- [ ] unchecked
|
||||
- [x] checked
|
||||
- [ ] still unchecked
|
||||
|
||||
### Definition list
|
||||
|
||||
First Term
|
||||
: This is the definition of the first term.
|
||||
|
||||
Second Term
|
||||
: This is one definition of the second term.
|
||||
: This is another definition of the second term.
|
||||
|
||||
### Footnotes
|
||||
|
||||
Here is a simple footnote,[^1] and here is a longer one.[^bignote]
|
||||
|
||||
[^1]: This is the first footnote.
|
||||
|
||||
[^bignote]: Here is one with multiple paragraphs and code.
|
||||
|
||||
Indent paragraphs to include them in the footnote.
|
||||
|
||||
` + "`{ my code }`" + `
|
||||
|
||||
Add as many paragraphs as you like.
|
||||
`,
|
||||
`
|
||||
- [ ] <!-- rebase-check --> If you want to rebase/retry this PR, click this checkbox.
|
||||
|
||||
---
|
||||
|
||||
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
|
||||
|
||||
<!-- test-comment -->`,
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
testAnswers := []string{
|
||||
`<p>Wiki! Enjoy :)</p>
|
||||
<ul>
|
||||
<li><a href="` + baseURL + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="` + baseURL + `/Tips" rel="nofollow">Tips</a></li>
|
||||
</ul>
|
||||
<p>See commit <a href="/` + testRepoOwnerName + `/` + testRepoName + `/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
|
||||
<p>Ideas and codes</p>
|
||||
<ul>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="` + FullURL + `issues/786" class="ref-issue" rel="nofollow">#786</a></li>
|
||||
<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
|
||||
<li><a href="` + baseURL + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
|
||||
<li><a href="` + baseURL + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
|
||||
</ul>
|
||||
`,
|
||||
`<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2>
|
||||
<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
|
||||
<h2 id="user-content-quick-links">Quick Links</h2>
|
||||
<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="` + baseURL + `/images/icon-install.png" rel="nofollow"><img src="` + baseURL + `/images/icon-install.png" title="icon-install.png" alt="images/icon-install.png"/></a></th>
|
||||
<th><a href="` + baseURL + `/Installation" rel="nofollow">Installation</a></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="` + baseURL + `/images/icon-usage.png" rel="nofollow"><img src="` + baseURL + `/images/icon-usage.png" title="icon-usage.png" alt="images/icon-usage.png"/></a></td>
|
||||
<td><a href="` + baseURL + `/Usage" rel="nofollow">Usage</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
`<p><a href="http://www.excelsiorjet.com/" rel="nofollow">Excelsior JET</a> allows you to create native executables for Windows, Linux and Mac OS X.</p>
|
||||
<ol>
|
||||
<li><a href="https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop" rel="nofollow">Package your libGDX application</a>
|
||||
<a href="` + baseURL + `/images/1.png" rel="nofollow"><img src="` + baseURL + `/images/1.png" title="1.png" alt="images/1.png"/></a></li>
|
||||
<li>Perform a test run by hitting the Run! button.
|
||||
<a href="` + baseURL + `/images/2.png" rel="nofollow"><img src="` + baseURL + `/images/2.png" title="2.png" alt="images/2.png"/></a></li>
|
||||
</ol>
|
||||
<h2 id="user-content-custom-id">More tests</h2>
|
||||
<p>(from <a href="https://www.markdownguide.org/extended-syntax/" rel="nofollow">https://www.markdownguide.org/extended-syntax/</a>)</p>
|
||||
<h3 id="user-content-checkboxes">Checkboxes</h3>
|
||||
<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="434"/>unchecked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="450" checked=""/>checked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="464"/>still unchecked</li>
|
||||
</ul>
|
||||
<h3 id="user-content-definition-list">Definition list</h3>
|
||||
<dl>
|
||||
<dt>First Term</dt>
|
||||
<dd>This is the definition of the first term.</dd>
|
||||
<dt>Second Term</dt>
|
||||
<dd>This is one definition of the second term.</dd>
|
||||
<dd>This is another definition of the second term.</dd>
|
||||
</dl>
|
||||
<h3 id="user-content-footnotes">Footnotes</h3>
|
||||
<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1 </a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">2 </a></sup></p>
|
||||
<div>
|
||||
<hr/>
|
||||
<ol>
|
||||
<li id="fn:user-content-1">
|
||||
<p>This is the first footnote. <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
<li id="fn:user-content-bignote">
|
||||
<p>Here is one with multiple paragraphs and code.</p>
|
||||
<p>Indent paragraphs to include them in the footnote.</p>
|
||||
<p><code>{ my code }</code></p>
|
||||
<p>Add as many paragraphs as you like. <a href="#fnref:user-content-bignote" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
`,
|
||||
`<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="3"/> If you want to rebase/retry this PR, click this checkbox.</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p>This PR has been generated by <a href="https://github.com/renovatebot/renovate" rel="nofollow">Renovate Bot</a>.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||
return username == "r-lyeh"
|
||||
},
|
||||
})
|
||||
for i := range sameCases {
|
||||
line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas).WithEnableHeadingIDGeneration(true), sameCases[i])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testAnswers[i], string(line))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RenderParagraphs(t *testing.T) {
|
||||
test := func(t *testing.T, str string, cnt int) {
|
||||
res, err := markdown.RenderRawString(markup.NewTestRenderContext(), str)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for unix should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
|
||||
|
||||
mac := strings.ReplaceAll(str, "\n", "\r")
|
||||
res, err = markdown.RenderRawString(markup.NewTestRenderContext(), mac)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for mac should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
|
||||
|
||||
dos := strings.ReplaceAll(str, "\n", "\r\n")
|
||||
res, err = markdown.RenderRawString(markup.NewTestRenderContext(), dos)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for windows should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res)
|
||||
}
|
||||
|
||||
test(t, "\nOne\nTwo\nThree", 1)
|
||||
test(t, "\n\nOne\nTwo\nThree", 1)
|
||||
test(t, "\n\nOne\nTwo\nThree\n\n\n", 1)
|
||||
test(t, "A\n\nB\nC\n", 2)
|
||||
test(t, "A\n\n\nB\nC\n", 2)
|
||||
}
|
||||
|
||||
func TestMarkdownRenderRaw(t *testing.T) {
|
||||
testcases := [][]byte{
|
||||
{ // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6267570554535936
|
||||
0x2a, 0x20, 0x2d, 0x0a, 0x09, 0x20, 0x60, 0x5b, 0x0a, 0x09, 0x20, 0x60,
|
||||
0x5b,
|
||||
},
|
||||
{ // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6278827345051648
|
||||
0x2d, 0x20, 0x2d, 0x0d, 0x09, 0x60, 0x0d, 0x09, 0x60,
|
||||
},
|
||||
{ // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6016973788020736[] = {
|
||||
0x7b, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x3d, 0x35, 0x7d, 0x0a, 0x3d,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testcase := range testcases {
|
||||
log.Info("Test markdown render error with fuzzy data: %x, the following errors can be recovered", testcase)
|
||||
_, err := markdown.RenderRawString(markup.NewTestRenderContext(), string(testcase))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSiblingImages_Issue12925(t *testing.T) {
|
||||
testcase := `
|
||||

|
||||
`
|
||||
expected := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"/></a>
|
||||
<a href="/image2" target="_blank" rel="nofollow noopener"><img src="/image2" alt="image2"/></a></p>
|
||||
`
|
||||
res, err := markdown.RenderString(markup.NewTestRenderContext(), testcase)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(res))
|
||||
}
|
||||
|
||||
func TestRenderEmojiInLinks_Issue12331(t *testing.T) {
|
||||
testcase := `[Link with emoji :moon: in text](https://gitea.io)`
|
||||
expected := `<p><a href="https://gitea.io" rel="nofollow">Link with emoji <span class="emoji" aria-label="waxing gibbous moon">🌔</span> in text</a></p>
|
||||
`
|
||||
res, err := markdown.RenderString(markup.NewTestRenderContext(), testcase)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, template.HTML(expected), res)
|
||||
}
|
||||
|
||||
func TestColorPreview(t *testing.T) {
|
||||
const nl = "\n"
|
||||
positiveTests := []struct {
|
||||
testcase string
|
||||
expected string
|
||||
}{
|
||||
{ // do not render color names
|
||||
"The CSS class `red` is there",
|
||||
"<p>The CSS class <code>red</code> is there</p>\n",
|
||||
},
|
||||
{ // hex
|
||||
"`#FF0000`",
|
||||
`<p><code>#FF0000<span class="color-preview" style="background-color: #FF0000"></span></code></p>` + nl,
|
||||
},
|
||||
{ // rgb
|
||||
"`rgb(16, 32, 64)`",
|
||||
`<p><code>rgb(16, 32, 64)<span class="color-preview" style="background-color: rgb(16, 32, 64)"></span></code></p>` + nl,
|
||||
},
|
||||
{ // short hex
|
||||
"This is the color white `#0a0`",
|
||||
`<p>This is the color white <code>#0a0<span class="color-preview" style="background-color: #0a0"></span></code></p>` + nl,
|
||||
},
|
||||
{ // hsl
|
||||
"HSL stands for hue, saturation, and lightness. An example: `hsl(0, 100%, 50%)`.",
|
||||
`<p>HSL stands for hue, saturation, and lightness. An example: <code>hsl(0, 100%, 50%)<span class="color-preview" style="background-color: hsl(0, 100%, 50%)"></span></code>.</p>` + nl,
|
||||
},
|
||||
{ // uppercase hsl
|
||||
"HSL stands for hue, saturation, and lightness. An example: `HSL(0, 100%, 50%)`.",
|
||||
`<p>HSL stands for hue, saturation, and lightness. An example: <code>HSL(0, 100%, 50%)<span class="color-preview" style="background-color: HSL(0, 100%, 50%)"></span></code>.</p>` + nl,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range positiveTests {
|
||||
res, err := markdown.RenderString(markup.NewTestRenderContext(), test.testcase)
|
||||
assert.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
|
||||
assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase)
|
||||
}
|
||||
|
||||
negativeTests := []string{
|
||||
// not a color code
|
||||
"`FF0000`",
|
||||
// inside a code block
|
||||
"```javascript" + nl + `const red = "#FF0000";` + nl + "```",
|
||||
// no backticks
|
||||
"rgb(166, 32, 64)",
|
||||
// typo
|
||||
"`hsI(0, 100%, 50%)`",
|
||||
// looks like a color but not really
|
||||
"`hsl(40, 60, 80)`",
|
||||
}
|
||||
|
||||
for _, test := range negativeTests {
|
||||
res, err := markdown.RenderString(markup.NewTestRenderContext(), test)
|
||||
assert.NoError(t, err, "Unexpected error in testcase: %q", test)
|
||||
assert.NotContains(t, res, `<span class="color-preview" style="background-color: `, "Unexpected result in testcase %q", test)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownFrontmatter(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"MapInFrontmatter",
|
||||
`---
|
||||
key1: val1
|
||||
key2: val2
|
||||
---
|
||||
test
|
||||
`,
|
||||
`<details class="frontmatter-content"><summary><span>octicon-table(12/)</span> key1, key2</summary><table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>key1</th>
|
||||
<th>key2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>val1</td>
|
||||
<td>val2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details><p>test</p>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
"ListInFrontmatter",
|
||||
`---
|
||||
- item1
|
||||
- item2
|
||||
---
|
||||
test
|
||||
`,
|
||||
`<hr/>
|
||||
<ul>
|
||||
<li>item1</li>
|
||||
<li>item2</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p>test</p>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
"StringInFrontmatter",
|
||||
`---
|
||||
anything
|
||||
---
|
||||
test
|
||||
`,
|
||||
`<hr/>
|
||||
<h2>anything</h2>
|
||||
<p>test</p>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
// data-source-position should take into account YAML frontmatter.
|
||||
"ListAfterFrontmatter",
|
||||
`---
|
||||
foo: bar
|
||||
---
|
||||
- [ ] task 1`,
|
||||
`<details class="frontmatter-content"><summary><span>octicon-table(12/)</span> foo</summary><table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>foo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>bar</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details><ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="19"/>task 1</li>
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
// we have our own frontmatter parser, don't need to use github.com/yuin/goldmark-meta
|
||||
{
|
||||
"InvalidFrontmatter",
|
||||
`---
|
||||
foo
|
||||
`,
|
||||
`<hr/>
|
||||
<p>foo</p>
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testcases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res, err := markdown.RenderString(markup.NewTestRenderContext(), tt.input)
|
||||
assert.NoError(t, err, "Unexpected error in testcase: %q", tt.name)
|
||||
assert.Equal(t, tt.expected, string(res), "Unexpected result in testcase %q", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLinks(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, AppURL)()
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
input := ` space @mention-user${SPACE}${SPACE}
|
||||
/just/a/path.bin
|
||||
https://example.com/file.bin
|
||||
[local link](file.bin)
|
||||
[remote link](https://example.com)
|
||||
[[local link|file.bin]]
|
||||
[[remote link|https://example.com]]
|
||||

|
||||

|
||||

|
||||

|
||||
[[local image|image.jpg]]
|
||||
[[remote link|https://example.com/image.jpg]]
|
||||
https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
||||
https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
:+1:
|
||||
mail@domain.com
|
||||
@mention-user test
|
||||
#123
|
||||
space${SPACE}${SPACE}
|
||||
`
|
||||
input = strings.ReplaceAll(input, "${SPACE}", " ") // replace ${SPACE} with " ", to avoid some editor's auto-trimming
|
||||
expected := `<p>space @mention-user<br/>
|
||||
/just/a/path.bin
|
||||
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
|
||||
<a href="/file.bin" rel="nofollow">local link</a>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a>
|
||||
<a href="/file.bin" rel="nofollow">local link</a>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a>
|
||||
<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a>
|
||||
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
|
||||
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
|
||||
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
|
||||
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
||||
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
||||
@mention-user test
|
||||
#123
|
||||
space</p>
|
||||
`
|
||||
result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
|
||||
t.Run("LocalCommitAndCompare", func(t *testing.T) {
|
||||
input := `http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
|
||||
http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash`
|
||||
|
||||
expected := `<p><a href="http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
|
||||
<a href="http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a></p>
|
||||
`
|
||||
result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMarkdownLink(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
input := `<a href=foo>link1</a>
|
||||
<a href='/foo'>link2</a>
|
||||
<a href="#foo">link3</a>`
|
||||
result, err := markdown.RenderString(markup.NewTestRenderContext("/base", localMetas), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<p><a href="/base/foo" rel="nofollow">link1</a>
|
||||
<a href="/base/foo" rel="nofollow">link2</a>
|
||||
<a href="#user-content-foo" rel="nofollow">link3</a></p>
|
||||
`, string(result))
|
||||
|
||||
input = "https://example.com/__init__.py"
|
||||
result, err = markdown.RenderString(markup.NewTestRenderContext("/base", localMetas), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<p><a href="https://example.com/__init__.py" rel="nofollow">https://example.com/__init__.py</a></p>
|
||||
`, string(result))
|
||||
}
|
||||
|
||||
func TestMarkdownUlDir(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, false)()
|
||||
result, err := markdown.RenderString(markup.NewTestRenderContext(), `
|
||||
* a
|
||||
* b
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<ul dir="auto">
|
||||
<li>a
|
||||
<ul>
|
||||
<li>b</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
`, string(result))
|
||||
}
|
||||
|
||||
func TestMarkdownCodeBlock(t *testing.T) {
|
||||
testRender := func(input, expected string) {
|
||||
buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
|
||||
}
|
||||
const nl = "\n"
|
||||
const prefix = `<div class="code-block-container code-overflow-scroll"><pre class="code-block">`
|
||||
const suffix = `</pre></div>`
|
||||
|
||||
testRender("```\ncode\n```", prefix+`<code class="chroma language-text display">code`+nl+`</code>`+suffix)
|
||||
|
||||
const jsCommon = prefix + `<code class="chroma language-js display"><span class="nx">code</span>` + nl + `</code>` + suffix
|
||||
testRender("```js\ncode\n```", jsCommon)
|
||||
testRender("```js:app.ts\ncode\n```", jsCommon)
|
||||
testRender("```js,ignore\ncode\n```", jsCommon)
|
||||
testRender("```js ignore\ncode\n```", jsCommon)
|
||||
testRender(" code\n", prefix+`<code>code`+nl+`</code>`+suffix)
|
||||
testRender(" <script>alert(1)</script>\n", prefix+`<code><script>alert(1)</script>`+nl+`</code>`+suffix)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import "github.com/yuin/goldmark/ast"
|
||||
|
||||
// Block represents a display math block e.g. $$...$$ or \[...\]
|
||||
type Block struct {
|
||||
ast.BaseBlock
|
||||
Dollars bool
|
||||
Indent int
|
||||
Closed bool
|
||||
Inline bool
|
||||
}
|
||||
|
||||
// KindBlock is the node kind for math blocks
|
||||
var KindBlock = ast.NewNodeKind("MathBlock")
|
||||
|
||||
// NewBlock creates a new math Block
|
||||
func NewBlock(dollars bool, indent int) *Block {
|
||||
return &Block{
|
||||
Dollars: dollars,
|
||||
Indent: indent,
|
||||
}
|
||||
}
|
||||
|
||||
// Dump dumps the block to a string
|
||||
func (n *Block) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// Kind returns KindBlock for math Blocks
|
||||
func (n *Block) Kind() ast.NodeKind {
|
||||
return KindBlock
|
||||
}
|
||||
|
||||
// IsRaw returns true as this block should not be processed further
|
||||
func (n *Block) IsRaw() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type blockParser struct {
|
||||
parseDollars bool
|
||||
parseSquare bool
|
||||
endBytesDollars []byte
|
||||
endBytesSquare []byte
|
||||
}
|
||||
|
||||
// NewBlockParser creates a new math BlockParser
|
||||
func NewBlockParser(parseDollars, parseSquare bool) parser.BlockParser {
|
||||
return &blockParser{
|
||||
parseDollars: parseDollars,
|
||||
parseSquare: parseSquare,
|
||||
endBytesDollars: []byte{'$', '$'},
|
||||
endBytesSquare: []byte{'\\', ']'},
|
||||
}
|
||||
}
|
||||
|
||||
// Open parses the current line and returns a result of parsing.
|
||||
func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
||||
line, segment := reader.PeekLine()
|
||||
pos := pc.BlockOffset()
|
||||
if pos == -1 || len(line[pos:]) < 2 {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
|
||||
var dollars bool
|
||||
if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' {
|
||||
dollars = true
|
||||
} else if b.parseSquare && line[pos] == '\\' && line[pos+1] == '[' {
|
||||
if len(line[pos:]) >= 3 && line[pos+2] == '!' && bytes.Contains(line[pos:], []byte(`\]`)) {
|
||||
// do not process escaped attention block: "> \[!NOTE\]"
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
dollars = false
|
||||
} else {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
|
||||
node := NewBlock(dollars, pos)
|
||||
|
||||
// Now we need to check if the ending block is on the segment...
|
||||
endBytes := giteaUtil.Iif(dollars, b.endBytesDollars, b.endBytesSquare)
|
||||
idx := bytes.Index(line[pos+2:], endBytes)
|
||||
if idx >= 0 {
|
||||
// for case: "$$ ... $$ any other text" (this case will be handled by the inline parser)
|
||||
for i := pos + 2 + idx + 2; i < len(line); i++ {
|
||||
if line[i] != ' ' && line[i] != '\n' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
}
|
||||
segment.Start += pos + 2
|
||||
segment.Stop = segment.Start + idx
|
||||
node.Lines().Append(segment)
|
||||
node.Closed = true
|
||||
node.Inline = true
|
||||
return node, parser.Close | parser.NoChildren
|
||||
}
|
||||
|
||||
// for case "\[ ... ]" (no close marker on the same line)
|
||||
for i := pos + 2 + idx + 2; i < len(line); i++ {
|
||||
if line[i] != ' ' && line[i] != '\n' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
}
|
||||
|
||||
segment.Start += pos + 2
|
||||
node.Lines().Append(segment)
|
||||
return node, parser.NoChildren
|
||||
}
|
||||
|
||||
// Continue parses the current line and returns a result of parsing.
|
||||
func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
||||
block := node.(*Block)
|
||||
if block.Closed {
|
||||
return parser.Close
|
||||
}
|
||||
|
||||
line, segment := reader.PeekLine()
|
||||
w, pos := util.IndentWidth(line, reader.LineOffset())
|
||||
if w < 4 {
|
||||
endBytes := giteaUtil.Iif(block.Dollars, b.endBytesDollars, b.endBytesSquare)
|
||||
if bytes.HasPrefix(line[pos:], endBytes) && util.IsBlank(line[pos+len(endBytes):]) {
|
||||
if util.IsBlank(line[pos+len(endBytes):]) {
|
||||
newline := giteaUtil.Iif(line[len(line)-1] != '\n', 0, 1)
|
||||
reader.Advance(segment.Stop - segment.Start - newline + segment.Padding)
|
||||
return parser.Close
|
||||
}
|
||||
}
|
||||
}
|
||||
start := segment.Start + giteaUtil.Iif(pos > block.Indent, block.Indent, pos)
|
||||
seg := text.NewSegmentPadding(start, segment.Stop, segment.Padding)
|
||||
node.Lines().Append(seg)
|
||||
return parser.Continue | parser.NoChildren
|
||||
}
|
||||
|
||||
// Close will be called when the parser returns Close.
|
||||
func (b *blockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
|
||||
// noop
|
||||
}
|
||||
|
||||
// CanInterruptParagraph returns true if the parser can interrupt paragraphs,
|
||||
// otherwise false.
|
||||
func (b *blockParser) CanInterruptParagraph() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// CanAcceptIndentedLine returns true if the parser can open new node when
|
||||
// the given line is being indented more than 3 spaces.
|
||||
func (b *blockParser) CanAcceptIndentedLine() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trigger returns a list of characters that triggers Parse method of
|
||||
// this parser.
|
||||
// If Trigger returns a nil, Open will be called with any lines.
|
||||
//
|
||||
// We leave this as nil as our parse method is quick enough
|
||||
func (b *blockParser) Trigger() []byte {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"gitea.dev/modules/markup/internal"
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Block render output:
|
||||
// <pre class="code-block is-loading"><code class="language-math display">...</code></pre>
|
||||
//
|
||||
// Keep in mind that there is another "code block" render in "func (r *GlodmarkRender) highlightingRenderer"
|
||||
// "highlightingRenderer" outputs the math block with extra "chroma" class:
|
||||
// <pre class="code-block is-loading"><code class="chroma language-math display">...</code></pre>
|
||||
//
|
||||
// Special classes:
|
||||
// * "is-loading": show a loading indicator
|
||||
// * "display": used by JS to decide to render as a block, otherwise render as inline
|
||||
|
||||
// BlockRenderer represents a renderer for math Blocks
|
||||
type BlockRenderer struct {
|
||||
renderInternal *internal.RenderInternal
|
||||
}
|
||||
|
||||
// NewBlockRenderer creates a new renderer for math Blocks
|
||||
func NewBlockRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
|
||||
return &BlockRenderer{renderInternal: renderInternal}
|
||||
}
|
||||
|
||||
// RegisterFuncs registers the renderer for math Blocks
|
||||
func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(KindBlock, r.renderBlock)
|
||||
}
|
||||
|
||||
func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) {
|
||||
l := n.Lines().Len()
|
||||
for i := range l {
|
||||
line := n.Lines().At(i)
|
||||
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
n := node.(*Block)
|
||||
if entering {
|
||||
codeHTML := giteaUtil.Iif[template.HTML](n.Inline, "", `<pre class="code-block is-loading">`) + `<code class="language-math display">`
|
||||
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(codeHTML)))
|
||||
r.writeLines(w, source, n)
|
||||
} else {
|
||||
_, _ = w.WriteString(`</code>` + giteaUtil.Iif(n.Inline, "", `</pre>`) + "\n")
|
||||
}
|
||||
return gast.WalkContinue, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Inline struct represents inline math e.g. $...$ or \(...\)
|
||||
type Inline struct {
|
||||
ast.BaseInline
|
||||
}
|
||||
|
||||
// Inline implements Inline.Inline.
|
||||
func (n *Inline) Inline() {}
|
||||
|
||||
// IsBlank returns if this inline node is empty
|
||||
func (n *Inline) IsBlank(source []byte) bool {
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
text := c.(*ast.Text).Segment
|
||||
if !util.IsBlank(text.Value(source)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Dump renders this inline math as debug
|
||||
func (n *Inline) Dump(source []byte, level int) {
|
||||
ast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
// KindInline is the kind for math inline
|
||||
var KindInline = ast.NewNodeKind("MathInline")
|
||||
|
||||
// Kind returns KindInline
|
||||
func (n *Inline) Kind() ast.NodeKind {
|
||||
return KindInline
|
||||
}
|
||||
|
||||
// NewInline creates a new ast math inline node
|
||||
func NewInline() *Inline {
|
||||
return &Inline{
|
||||
BaseInline: ast.BaseInline{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
type inlineParser struct {
|
||||
trigger []byte
|
||||
endBytesSingleDollar []byte
|
||||
endBytesDoubleDollar []byte
|
||||
endBytesParentheses []byte
|
||||
enableInlineDollar bool
|
||||
}
|
||||
|
||||
func NewInlineDollarParser(enableInlineDollar bool) parser.InlineParser {
|
||||
return &inlineParser{
|
||||
trigger: []byte{'$'},
|
||||
endBytesSingleDollar: []byte{'$'},
|
||||
endBytesDoubleDollar: []byte{'$', '$'},
|
||||
enableInlineDollar: enableInlineDollar,
|
||||
}
|
||||
}
|
||||
|
||||
var defaultInlineParenthesesParser = &inlineParser{
|
||||
trigger: []byte{'\\', '('},
|
||||
endBytesParentheses: []byte{'\\', ')'},
|
||||
}
|
||||
|
||||
func NewInlineParenthesesParser() parser.InlineParser {
|
||||
return defaultInlineParenthesesParser
|
||||
}
|
||||
|
||||
// Trigger triggers this parser on $ or \
|
||||
func (parser *inlineParser) Trigger() []byte {
|
||||
return parser.trigger
|
||||
}
|
||||
|
||||
func isPunctuation(b byte) bool {
|
||||
return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':'
|
||||
}
|
||||
|
||||
func isParenthesesClose(b byte) bool {
|
||||
return b == ')'
|
||||
}
|
||||
|
||||
func isAlphanumeric(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
func isInMarkdownLinkText(block text.Reader, lineAfter []byte) bool {
|
||||
return block.PrecendingCharacter() == '[' && bytes.HasPrefix(lineAfter, []byte("]("))
|
||||
}
|
||||
|
||||
// Parse parses the current line and returns a result of parsing.
|
||||
func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
line, _ := block.PeekLine()
|
||||
|
||||
if !bytes.HasPrefix(line, parser.trigger) {
|
||||
// We'll catch this one on the next time round
|
||||
return nil
|
||||
}
|
||||
|
||||
var startMarkLen int
|
||||
var stopMark []byte
|
||||
checkSurrounding := true
|
||||
if line[0] == '$' {
|
||||
startMarkLen = 1
|
||||
stopMark = parser.endBytesSingleDollar
|
||||
if len(line) > 1 {
|
||||
switch line[1] {
|
||||
case '$':
|
||||
startMarkLen = 2
|
||||
stopMark = parser.endBytesDoubleDollar
|
||||
case '`':
|
||||
pos := 1
|
||||
for ; pos < len(line) && line[pos] == '`'; pos++ {
|
||||
}
|
||||
startMarkLen = pos
|
||||
stopMark = bytes.Repeat([]byte{'`'}, pos)
|
||||
stopMark[len(stopMark)-1] = '$'
|
||||
checkSurrounding = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startMarkLen = 2
|
||||
stopMark = parser.endBytesParentheses
|
||||
}
|
||||
|
||||
if line[0] == '$' && !parser.enableInlineDollar && (len(line) == 1 || line[1] != '`') {
|
||||
return nil
|
||||
}
|
||||
|
||||
if checkSurrounding {
|
||||
precedingCharacter := block.PrecendingCharacter()
|
||||
if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) {
|
||||
// need to exclude things like `a$` from being considered a start
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// move the opener marker point at the start of the text
|
||||
opener := startMarkLen
|
||||
|
||||
// Now look for an ending line
|
||||
depth := 0
|
||||
ender := -1
|
||||
for i := opener; i < len(line); i++ {
|
||||
if depth == 0 && bytes.HasPrefix(line[i:], stopMark) {
|
||||
succeedingCharacter := byte(0)
|
||||
if i+len(stopMark) < len(line) {
|
||||
succeedingCharacter = line[i+len(stopMark)]
|
||||
}
|
||||
// check valid ending character
|
||||
isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) ||
|
||||
succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 ||
|
||||
succeedingCharacter == '$' ||
|
||||
isInMarkdownLinkText(block, line[i+len(stopMark):])
|
||||
if checkSurrounding && !isValidEndingChar {
|
||||
break
|
||||
}
|
||||
ender = i
|
||||
break
|
||||
}
|
||||
if line[i] == '\\' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch line[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
if ender == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
block.Advance(opener)
|
||||
_, pos := block.Position()
|
||||
node := NewInline()
|
||||
|
||||
segment := pos.WithStop(pos.Start + ender - opener)
|
||||
node.AppendChild(node, ast.NewRawTextSegment(segment))
|
||||
block.Advance(ender - opener + len(stopMark))
|
||||
trimBlock(node, block)
|
||||
return node
|
||||
}
|
||||
|
||||
func trimBlock(node *Inline, block text.Reader) {
|
||||
if node.IsBlank(block.Source()) {
|
||||
return
|
||||
}
|
||||
|
||||
// trim first space and last space
|
||||
first := node.FirstChild().(*ast.Text)
|
||||
if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') {
|
||||
return
|
||||
}
|
||||
|
||||
last := node.LastChild().(*ast.Text)
|
||||
if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') {
|
||||
return
|
||||
}
|
||||
|
||||
first.Segment = first.Segment.WithStart(first.Segment.Start + 1)
|
||||
last.Segment = last.Segment.WithStop(last.Segment.Stop - 1)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"gitea.dev/modules/markup/internal"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Inline render output:
|
||||
// <code class="language-math">...</code>
|
||||
|
||||
// InlineRenderer is an inline renderer
|
||||
type InlineRenderer struct {
|
||||
renderInternal *internal.RenderInternal
|
||||
}
|
||||
|
||||
// NewInlineRenderer returns a new renderer for inline math
|
||||
func NewInlineRenderer(renderInternal *internal.RenderInternal) renderer.NodeRenderer {
|
||||
return &InlineRenderer{renderInternal: renderInternal}
|
||||
}
|
||||
|
||||
func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(`<code class="language-math">`)))
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := util.EscapeHTML(segment.Value(source))
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
if c != n.LastChild() {
|
||||
_, _ = w.Write([]byte(" "))
|
||||
}
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString(`</code>`)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// RegisterFuncs registers the renderer for inline math nodes
|
||||
func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(KindInline, r.renderInline)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package math
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/markup/internal"
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
ParseInlineDollar bool // inline $$ xxx $$ text
|
||||
ParseInlineParentheses bool // inline \( xxx \) text
|
||||
ParseBlockDollar bool // block $$ multiple-line $$ text
|
||||
ParseBlockSquareBrackets bool // block \[ multiple-line \] text
|
||||
}
|
||||
|
||||
// Extension is a math extension
|
||||
type Extension struct {
|
||||
renderInternal *internal.RenderInternal
|
||||
options Options
|
||||
}
|
||||
|
||||
// NewExtension creates a new math extension with the provided options
|
||||
func NewExtension(renderInternal *internal.RenderInternal, opts ...Options) *Extension {
|
||||
opt := giteaUtil.OptionalArg(opts)
|
||||
r := &Extension{
|
||||
renderInternal: renderInternal,
|
||||
options: opt,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Extend extends goldmark with our parsers and renderers
|
||||
func (e *Extension) Extend(m goldmark.Markdown) {
|
||||
if !e.options.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
var inlines []util.PrioritizedValue
|
||||
if e.options.ParseInlineParentheses {
|
||||
inlines = append(inlines, util.Prioritized(NewInlineParenthesesParser(), 501))
|
||||
}
|
||||
inlines = append(inlines, util.Prioritized(NewInlineDollarParser(e.options.ParseInlineDollar), 502))
|
||||
|
||||
m.Parser().AddOptions(parser.WithInlineParsers(inlines...))
|
||||
m.Parser().AddOptions(parser.WithBlockParsers(
|
||||
util.Prioritized(NewBlockParser(e.options.ParseBlockDollar, e.options.ParseBlockSquareBrackets), 701),
|
||||
))
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(NewBlockRenderer(e.renderInternal), 501),
|
||||
util.Prioritized(NewInlineRenderer(e.renderInternal), 502),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func isYAMLSeparator(line []byte) bool {
|
||||
idx := 0
|
||||
for ; idx < len(line); idx++ {
|
||||
if line[idx] >= utf8.RuneSelf {
|
||||
r, sz := utf8.DecodeRune(line[idx:])
|
||||
if !unicode.IsSpace(r) {
|
||||
return false
|
||||
}
|
||||
idx += sz
|
||||
continue
|
||||
}
|
||||
if line[idx] != ' ' {
|
||||
break
|
||||
}
|
||||
}
|
||||
dashCount := 0
|
||||
for ; idx < len(line); idx++ {
|
||||
if line[idx] != '-' {
|
||||
break
|
||||
}
|
||||
dashCount++
|
||||
}
|
||||
if dashCount < 3 {
|
||||
return false
|
||||
}
|
||||
for ; idx < len(line); idx++ {
|
||||
if line[idx] >= utf8.RuneSelf {
|
||||
r, sz := utf8.DecodeRune(line[idx:])
|
||||
if !unicode.IsSpace(r) {
|
||||
return false
|
||||
}
|
||||
idx += sz
|
||||
continue
|
||||
}
|
||||
if line[idx] != ' ' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExtractMetadata consumes a markdown file, parses YAML frontmatter,
|
||||
// and returns the frontmatter metadata separated from the markdown content
|
||||
func ExtractMetadata(contents string, out any) (string, error) {
|
||||
body, err := ExtractMetadataBytes([]byte(contents), out)
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
// ExtractMetadataBytes consumes a Markdown content, parses YAML frontmatter,
|
||||
// and returns the frontmatter metadata separated from the Markdown content
|
||||
func ExtractMetadataBytes(contents []byte, out any) ([]byte, error) {
|
||||
var front, body []byte
|
||||
|
||||
start, end := 0, len(contents)
|
||||
idx := bytes.IndexByte(contents[start:], '\n')
|
||||
if idx >= 0 {
|
||||
end = start + idx
|
||||
}
|
||||
line := contents[start:end]
|
||||
|
||||
if !isYAMLSeparator(line) {
|
||||
return contents, errors.New("frontmatter must start with a separator line")
|
||||
}
|
||||
frontMatterStart := end + 1
|
||||
for start = frontMatterStart; start < len(contents); start = end + 1 {
|
||||
end = len(contents)
|
||||
idx := bytes.IndexByte(contents[start:], '\n')
|
||||
if idx >= 0 {
|
||||
end = start + idx
|
||||
}
|
||||
line := contents[start:end]
|
||||
if isYAMLSeparator(line) {
|
||||
front = contents[frontMatterStart:start]
|
||||
if end+1 < len(contents) {
|
||||
body = contents[end+1:]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(front) == 0 {
|
||||
return contents, errors.New("could not determine metadata")
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(front, out); err != nil {
|
||||
return contents, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// IssueTemplate is a legacy to keep the unit tests working.
|
||||
// Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template.
|
||||
type IssueTemplate struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"`
|
||||
Labels []string `json:"labels" yaml:"labels"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
}
|
||||
|
||||
func (it *IssueTemplate) Valid() bool {
|
||||
return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != ""
|
||||
}
|
||||
|
||||
func TestExtractMetadata(t *testing.T) {
|
||||
t.Run("ValidFrontAndBody", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest), &meta)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, bodyTest, body)
|
||||
assert.Equal(t, metaTest, meta)
|
||||
assert.True(t, meta.Valid())
|
||||
})
|
||||
|
||||
t.Run("NoFirstSeparator", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
_, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest), &meta)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NoLastSeparator", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
_, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest), &meta)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NoBody", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest), &meta)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, body)
|
||||
assert.Equal(t, metaTest, meta)
|
||||
assert.True(t, meta.Valid())
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractMetadataBytes(t *testing.T) {
|
||||
t.Run("ValidFrontAndBody", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
body, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest), &meta)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, bodyTest, string(body))
|
||||
assert.Equal(t, metaTest, meta)
|
||||
assert.True(t, meta.Valid())
|
||||
})
|
||||
|
||||
t.Run("NoFirstSeparator", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
_, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", frontTest, sepTest, bodyTest), &meta)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NoLastSeparator", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
_, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", sepTest, frontTest, bodyTest), &meta)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NoBody", func(t *testing.T) {
|
||||
var meta IssueTemplate
|
||||
body, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", sepTest, frontTest, sepTest), &meta)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, string(body))
|
||||
assert.Equal(t, metaTest, meta)
|
||||
assert.True(t, meta.Valid())
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
sepTest = "-----"
|
||||
frontTest = `name: Test
|
||||
about: "A Test"
|
||||
title: "Test Title"
|
||||
labels:
|
||||
- bug
|
||||
- "test label"`
|
||||
bodyTest = "This is the body"
|
||||
metaTest = IssueTemplate{
|
||||
Name: "Test",
|
||||
About: "A Test",
|
||||
Title: "Test Title",
|
||||
Labels: []string{"bug", "test label"},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// RenderConfig represents rendering configuration for this file
|
||||
type RenderConfig struct {
|
||||
Meta markup.RenderMetaMode
|
||||
TOC string // "false": hide, "side"/empty: in sidebar, "main"/"true": in main view
|
||||
Lang string
|
||||
yamlNode *yaml.Node
|
||||
|
||||
// Used internally. Cannot be controlled by frontmatter.
|
||||
metaLength int
|
||||
}
|
||||
|
||||
func renderMetaModeFromString(s string) markup.RenderMetaMode {
|
||||
switch strings.TrimSpace(strings.ToLower(s)) {
|
||||
case "none":
|
||||
return markup.RenderMetaAsNone
|
||||
case "table":
|
||||
return markup.RenderMetaAsTable
|
||||
default: // "details"
|
||||
return markup.RenderMetaAsDetails
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalYAML implement yaml.v3 UnmarshalYAML
|
||||
func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
if rc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rc.yamlNode = value
|
||||
|
||||
type commonRenderConfig struct {
|
||||
TOC string `yaml:"include_toc"`
|
||||
Lang string `yaml:"lang"`
|
||||
}
|
||||
var basic commonRenderConfig
|
||||
if err := value.Decode(&basic); err != nil {
|
||||
return fmt.Errorf("unable to decode into commonRenderConfig %w", err)
|
||||
}
|
||||
|
||||
if basic.Lang != "" {
|
||||
rc.Lang = basic.Lang
|
||||
}
|
||||
|
||||
rc.TOC = basic.TOC
|
||||
|
||||
type controlStringRenderConfig struct {
|
||||
Gitea string `yaml:"gitea"`
|
||||
}
|
||||
|
||||
var stringBasic controlStringRenderConfig
|
||||
|
||||
if err := value.Decode(&stringBasic); err == nil {
|
||||
if stringBasic.Gitea != "" {
|
||||
rc.Meta = renderMetaModeFromString(stringBasic.Gitea)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type yamlRenderConfig struct {
|
||||
Meta *string `yaml:"meta"`
|
||||
Icon *string `yaml:"details_icon"` // deprecated, because there is no font icon, so no custom icon
|
||||
TOC *string `yaml:"include_toc"`
|
||||
Lang *string `yaml:"lang"`
|
||||
}
|
||||
|
||||
type yamlRenderConfigWrapper struct {
|
||||
Gitea *yamlRenderConfig `yaml:"gitea"`
|
||||
}
|
||||
|
||||
var cfg yamlRenderConfigWrapper
|
||||
if err := value.Decode(&cfg); err != nil {
|
||||
return fmt.Errorf("unable to decode into yamlRenderConfigWrapper %w", err)
|
||||
}
|
||||
|
||||
if cfg.Gitea == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Gitea.Meta != nil {
|
||||
rc.Meta = renderMetaModeFromString(*cfg.Gitea.Meta)
|
||||
}
|
||||
|
||||
if cfg.Gitea.Lang != nil && *cfg.Gitea.Lang != "" {
|
||||
rc.Lang = *cfg.Gitea.Lang
|
||||
}
|
||||
|
||||
if cfg.Gitea.TOC != nil {
|
||||
rc.TOC = *cfg.Gitea.TOC
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RenderConfig) toMetaNode(g *ASTTransformer) ast.Node {
|
||||
if rc.yamlNode == nil {
|
||||
return nil
|
||||
}
|
||||
switch rc.Meta {
|
||||
case markup.RenderMetaAsTable:
|
||||
return nodeToTable(rc.yamlNode)
|
||||
case markup.RenderMetaAsDetails:
|
||||
return nodeToDetails(g, rc.yamlNode)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestRenderConfig_UnmarshalYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected *RenderConfig
|
||||
args string
|
||||
}{
|
||||
{
|
||||
"empty", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "",
|
||||
}, "",
|
||||
},
|
||||
{
|
||||
"lang", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "test",
|
||||
}, "lang: test",
|
||||
},
|
||||
{
|
||||
"metatable", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "",
|
||||
}, "gitea: table",
|
||||
},
|
||||
{
|
||||
"metanone", &RenderConfig{
|
||||
Meta: "none",
|
||||
Lang: "",
|
||||
}, "gitea: none",
|
||||
},
|
||||
{
|
||||
"metadetails", &RenderConfig{
|
||||
Meta: "details",
|
||||
Lang: "",
|
||||
}, "gitea: details",
|
||||
},
|
||||
{
|
||||
"metawrong", &RenderConfig{
|
||||
Meta: "details",
|
||||
Lang: "",
|
||||
}, "gitea: wrong",
|
||||
},
|
||||
{
|
||||
"toc", &RenderConfig{
|
||||
TOC: "true",
|
||||
Meta: "table",
|
||||
Lang: "",
|
||||
}, "include_toc: true",
|
||||
},
|
||||
{
|
||||
"tocfalse", &RenderConfig{
|
||||
TOC: "false",
|
||||
Meta: "table",
|
||||
Lang: "",
|
||||
}, "include_toc: false",
|
||||
},
|
||||
{
|
||||
"toclang", &RenderConfig{
|
||||
Meta: "table",
|
||||
TOC: "true",
|
||||
Lang: "testlang",
|
||||
}, `
|
||||
include_toc: true
|
||||
lang: testlang
|
||||
`,
|
||||
},
|
||||
{
|
||||
"complexlang", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "testlang",
|
||||
}, `
|
||||
gitea:
|
||||
lang: testlang
|
||||
`,
|
||||
},
|
||||
{
|
||||
"complexlang2", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "testlang",
|
||||
}, `
|
||||
lang: notright
|
||||
gitea:
|
||||
lang: testlang
|
||||
`,
|
||||
},
|
||||
{
|
||||
"complexlang", &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "testlang",
|
||||
}, `
|
||||
gitea:
|
||||
lang: testlang
|
||||
`,
|
||||
},
|
||||
{
|
||||
"complex2", &RenderConfig{
|
||||
Lang: "two",
|
||||
Meta: "table",
|
||||
TOC: "true",
|
||||
}, `
|
||||
lang: one
|
||||
include_toc: true
|
||||
gitea:
|
||||
details_icon: smiley
|
||||
meta: table
|
||||
include_toc: true
|
||||
lang: two
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := &RenderConfig{
|
||||
Meta: "table",
|
||||
Lang: "",
|
||||
}
|
||||
err := yaml.Unmarshal([]byte(strings.ReplaceAll(tt.args, "\t", " ")), got)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected.Meta, got.Meta)
|
||||
assert.Equal(t, tt.expected.Lang, got.Lang)
|
||||
assert.Equal(t, tt.expected.TOC, got.TOC)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// renderAttention renders a quote marked with i.e. "> **Note**" or "> [!Warning]" with a corresponding svg
|
||||
func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*Attention)
|
||||
var octiconName string
|
||||
switch n.AttentionType {
|
||||
case "tip":
|
||||
octiconName = "light-bulb"
|
||||
case "important":
|
||||
octiconName = "report"
|
||||
case "warning":
|
||||
octiconName = "alert"
|
||||
case "caution":
|
||||
octiconName = "stop"
|
||||
default: // including "note"
|
||||
octiconName = "info"
|
||||
}
|
||||
svgHTML := svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType)
|
||||
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(svgHTML)))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) extractBlockquoteAttentionEmphasis(firstParagraph ast.Node, reader text.Reader) (string, []ast.Node) {
|
||||
if firstParagraph.ChildCount() < 1 {
|
||||
return "", nil
|
||||
}
|
||||
node1, ok := firstParagraph.FirstChild().(*ast.Emphasis)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
val1 := string(node1.Text(reader.Source())) //nolint:staticcheck // Text is deprecated
|
||||
attentionType := strings.ToLower(val1)
|
||||
if g.attentionTypes.Contains(attentionType) {
|
||||
return attentionType, []ast.Node{node1}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) extractBlockquoteAttention2(firstParagraph ast.Node, reader text.Reader) (string, []ast.Node) {
|
||||
if firstParagraph.ChildCount() < 2 {
|
||||
return "", nil
|
||||
}
|
||||
node1, ok := firstParagraph.FirstChild().(*ast.Text)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
node2, ok := node1.NextSibling().(*ast.Text)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
val1 := string(node1.Segment.Value(reader.Source()))
|
||||
val2 := string(node2.Segment.Value(reader.Source()))
|
||||
if strings.HasPrefix(val1, `\[!`) && val2 == `\]` {
|
||||
attentionType := strings.ToLower(val1[3:])
|
||||
if g.attentionTypes.Contains(attentionType) {
|
||||
return attentionType, []ast.Node{node1, node2}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) extractBlockquoteAttention3(firstParagraph ast.Node, reader text.Reader) (string, []ast.Node) {
|
||||
if firstParagraph.ChildCount() < 3 {
|
||||
return "", nil
|
||||
}
|
||||
node1, ok := firstParagraph.FirstChild().(*ast.Text)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
node2, ok := node1.NextSibling().(*ast.Text)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
node3, ok := node2.NextSibling().(*ast.Text)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
val1 := string(node1.Segment.Value(reader.Source()))
|
||||
val2 := string(node2.Segment.Value(reader.Source()))
|
||||
val3 := string(node3.Segment.Value(reader.Source()))
|
||||
if val1 != "[" || val3 != "]" || !strings.HasPrefix(val2, "!") {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
attentionType := strings.ToLower(val2[1:])
|
||||
if g.attentionTypes.Contains(attentionType) {
|
||||
return attentionType, []ast.Node{node1, node2, node3}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) transformBlockquote(v *ast.Blockquote, reader text.Reader) (ast.WalkStatus, error) {
|
||||
// We only want attention blockquotes when the AST looks like:
|
||||
// > Text("[") Text("!TYPE") Text("]")
|
||||
// > Text("\[!TYPE") TEXT("\]")
|
||||
// > Text("**TYPE**")
|
||||
|
||||
// grab these nodes and make sure we adhere to the attention blockquote structure
|
||||
firstParagraph := v.FirstChild()
|
||||
if firstParagraph == nil {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
g.applyElementDir(firstParagraph)
|
||||
|
||||
attentionType, processedNodes := g.extractBlockquoteAttentionEmphasis(firstParagraph, reader)
|
||||
if attentionType == "" {
|
||||
attentionType, processedNodes = g.extractBlockquoteAttention2(firstParagraph, reader)
|
||||
}
|
||||
if attentionType == "" {
|
||||
attentionType, processedNodes = g.extractBlockquoteAttention3(firstParagraph, reader)
|
||||
}
|
||||
if attentionType == "" {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// color the blockquote
|
||||
v.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-header attention-"+attentionType)))
|
||||
|
||||
// create an emphasis to make it bold
|
||||
attentionParagraph := ast.NewParagraph()
|
||||
g.applyElementDir(attentionParagraph)
|
||||
emphasis := ast.NewEmphasis(2)
|
||||
emphasis.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("attention-"+attentionType)))
|
||||
|
||||
attentionAstString := ast.NewString([]byte(cases.Title(language.English).String(attentionType)))
|
||||
|
||||
// replace the ![TYPE] with a dedicated paragraph of icon+Type
|
||||
emphasis.AppendChild(emphasis, attentionAstString)
|
||||
attentionParagraph.AppendChild(attentionParagraph, NewAttention(attentionType))
|
||||
attentionParagraph.AppendChild(attentionParagraph, emphasis)
|
||||
firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph)
|
||||
for _, processed := range processedNodes {
|
||||
firstParagraph.RemoveChild(firstParagraph, processed)
|
||||
}
|
||||
if firstParagraph.ChildCount() == 0 {
|
||||
firstParagraph.Parent().RemoveChild(firstParagraph.Parent(), firstParagraph)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
func (g *ASTTransformer) transformFencedCodeblock(v *ast.FencedCodeBlock, reader text.Reader) {
|
||||
// * Some engines support a meta syntax for appending the filename after the language, separated by a colon
|
||||
// * https://www.glukhov.org/documentation-tools/markdown/markdown-codeblocks/
|
||||
// * Some engines support additional "options" after the language, separated by a space or comma: ```rust,ignore```
|
||||
// * https://docs.readme.com/rdmd/docs/code-blocks
|
||||
// * https://next-book.vercel.app/reference/fencedcode
|
||||
if v.Info == nil {
|
||||
return
|
||||
}
|
||||
info := v.Info.Segment.Value(reader.Source())
|
||||
newEnd := -1
|
||||
for i, b := range info {
|
||||
if b == ' ' || b == ',' || b == ':' {
|
||||
newEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if newEnd != -1 {
|
||||
start := v.Info.Segment.Start
|
||||
v.Info = ast.NewTextSegment(text.NewSegment(start, start+newEnd))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday/css"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// renderCodeSpan renders CodeSpan elements (like goldmark upstream does) but also renders ColorPreview elements.
|
||||
// See #21474 for reference
|
||||
func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.Attributes() != nil {
|
||||
_, _ = w.WriteString("<code")
|
||||
html.RenderAttributes(w, n, html.CodeAttributeFilter)
|
||||
_ = w.WriteByte('>')
|
||||
} else {
|
||||
_, _ = w.WriteString("<code>")
|
||||
}
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch v := c.(type) {
|
||||
case *ast.Text:
|
||||
segment := v.Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
r.Writer.RawWrite(w, value[:len(value)-1])
|
||||
r.Writer.RawWrite(w, []byte(" "))
|
||||
} else {
|
||||
r.Writer.RawWrite(w, value)
|
||||
}
|
||||
case *ColorPreview:
|
||||
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, string(v.Color))
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString("</code>")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// cssColorHandler checks if a string is a render-able CSS color value.
|
||||
// The code is from "github.com/microcosm-cc/bluemonday/css.ColorHandler", except that it doesn't handle color words like "red".
|
||||
func cssColorHandler(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
if css.HexRGB.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
if css.RGB.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
if css.RGBA.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
if css.HSL.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
return css.HSLA.MatchString(value)
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) {
|
||||
colorContent := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated
|
||||
if cssColorHandler(string(colorContent)) {
|
||||
v.AppendChild(v, NewColorPreview(colorContent))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*TaskCheckBoxListItem)
|
||||
if entering {
|
||||
if n.Attributes() != nil {
|
||||
_, _ = w.WriteString("<li")
|
||||
html.RenderAttributes(w, n, html.ListItemAttributeFilter)
|
||||
_ = w.WriteByte('>')
|
||||
} else {
|
||||
_, _ = w.WriteString("<li>")
|
||||
}
|
||||
fmt.Fprintf(w, `<input type="checkbox" disabled="" data-source-position="%d"`, n.SourcePosition)
|
||||
if n.IsChecked {
|
||||
_, _ = w.WriteString(` checked=""`)
|
||||
}
|
||||
if r.XHTML {
|
||||
_, _ = w.WriteString(` />`)
|
||||
} else {
|
||||
_ = w.WriteByte('>')
|
||||
}
|
||||
fc := n.FirstChild()
|
||||
if fc != nil {
|
||||
if _, ok := fc.(*ast.TextBlock); !ok {
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, _ = w.WriteString("</li>\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc *RenderConfig) {
|
||||
if v.HasChildren() {
|
||||
children := make([]ast.Node, 0, v.ChildCount())
|
||||
child := v.FirstChild()
|
||||
for child != nil {
|
||||
children = append(children, child)
|
||||
child = child.NextSibling()
|
||||
}
|
||||
v.RemoveChildren(v)
|
||||
|
||||
for _, child := range children {
|
||||
listItem := child.(*ast.ListItem)
|
||||
if !child.HasChildren() || !child.FirstChild().HasChildren() {
|
||||
v.AppendChild(v, child)
|
||||
continue
|
||||
}
|
||||
taskCheckBox, ok := child.FirstChild().FirstChild().(*east.TaskCheckBox)
|
||||
if !ok {
|
||||
v.AppendChild(v, child)
|
||||
continue
|
||||
}
|
||||
newChild := NewTaskCheckBoxListItem(listItem)
|
||||
newChild.IsChecked = taskCheckBox.IsChecked
|
||||
newChild.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("task-list-item")))
|
||||
segments := newChild.FirstChild().Lines()
|
||||
if segments.Len() > 0 {
|
||||
segment := segments.At(0)
|
||||
newChild.SourcePosition = rc.metaLength + segment.Start
|
||||
}
|
||||
v.AppendChild(v, newChild)
|
||||
}
|
||||
}
|
||||
|
||||
nestedList := false
|
||||
for p := v.Parent(); p != nil; p = p.Parent() {
|
||||
if _, ok := p.(*ast.List); ok {
|
||||
nestedList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nestedList {
|
||||
// "dir=auto" should be only added to top-level "ul". https://github.com/go-gitea/gitea/issues/35058
|
||||
g.applyElementDir(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mdstripper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
var (
|
||||
giteaHostInit sync.Once
|
||||
giteaHost *url.URL
|
||||
)
|
||||
|
||||
type stripRenderer struct {
|
||||
localhost *url.URL
|
||||
links []string
|
||||
empty bool
|
||||
}
|
||||
|
||||
func (r *stripRenderer) Render(w io.Writer, source []byte, doc ast.Node) error {
|
||||
return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch v := n.(type) {
|
||||
case *ast.Text:
|
||||
if !v.IsRaw() {
|
||||
_, prevSibIsText := n.PreviousSibling().(*ast.Text)
|
||||
coalesce := prevSibIsText
|
||||
r.processString(
|
||||
w,
|
||||
v.Value(source),
|
||||
coalesce)
|
||||
if v.SoftLineBreak() {
|
||||
r.doubleSpace(w)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
case *ast.Link:
|
||||
r.processLink(v.Destination)
|
||||
return ast.WalkSkipChildren, nil
|
||||
case *ast.AutoLink:
|
||||
// This could be a reference to an issue or pull - if so convert it
|
||||
r.processAutoLink(w, v.URL(source))
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *stripRenderer) doubleSpace(w io.Writer) {
|
||||
if !r.empty {
|
||||
_, _ = w.Write([]byte{'\n'})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stripRenderer) processString(w io.Writer, text []byte, coalesce bool) {
|
||||
// Always break-up words
|
||||
if !coalesce {
|
||||
r.doubleSpace(w)
|
||||
}
|
||||
_, _ = w.Write(text)
|
||||
r.empty = false
|
||||
}
|
||||
|
||||
// ProcessAutoLinks to detect and handle links to issues and pulls
|
||||
func (r *stripRenderer) processAutoLink(w io.Writer, link []byte) {
|
||||
linkStr := string(link)
|
||||
u, err := url.Parse(linkStr)
|
||||
if err != nil {
|
||||
// Process out of band
|
||||
r.links = append(r.links, linkStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: we're not attempting to match the URL scheme (http/https)
|
||||
if u.Host != "" && !strings.EqualFold(u.Host, r.localhost.Host) {
|
||||
// Process out of band
|
||||
r.links = append(r.links, linkStr)
|
||||
return
|
||||
}
|
||||
|
||||
// We want: /user/repo/issues/3
|
||||
parts := strings.Split(strings.TrimPrefix(u.EscapedPath(), r.localhost.EscapedPath()), "/")
|
||||
if len(parts) != 5 || parts[0] != "" {
|
||||
// Process out of band
|
||||
r.links = append(r.links, linkStr)
|
||||
return
|
||||
}
|
||||
|
||||
var sep string
|
||||
switch parts[3] {
|
||||
case "issues":
|
||||
sep = "#"
|
||||
case "pulls":
|
||||
sep = "!"
|
||||
default:
|
||||
// Process out of band
|
||||
r.links = append(r.links, linkStr)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(parts[1]))
|
||||
_, _ = w.Write([]byte("/"))
|
||||
_, _ = w.Write([]byte(parts[2]))
|
||||
_, _ = w.Write([]byte(sep))
|
||||
_, _ = w.Write([]byte(parts[4]))
|
||||
}
|
||||
|
||||
func (r *stripRenderer) processLink(link []byte) {
|
||||
// Links are processed out of band
|
||||
r.links = append(r.links, string(link))
|
||||
}
|
||||
|
||||
// GetLinks returns the list of link data collected while parsing
|
||||
func (r *stripRenderer) GetLinks() []string {
|
||||
return r.links
|
||||
}
|
||||
|
||||
// AddOptions adds given option to this renderer.
|
||||
func (r *stripRenderer) AddOptions(...renderer.Option) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
// StripMarkdown parses markdown content by removing all markup and code blocks
|
||||
// in order to extract links and other references
|
||||
func StripMarkdown(rawBytes []byte) (string, []string) {
|
||||
buf, links := StripMarkdownBytes(rawBytes)
|
||||
return string(buf), links
|
||||
}
|
||||
|
||||
var (
|
||||
stripParser parser.Parser
|
||||
once = sync.Once{}
|
||||
)
|
||||
|
||||
// StripMarkdownBytes parses markdown content by removing all markup and code blocks
|
||||
// in order to extract links and other references
|
||||
func StripMarkdownBytes(rawBytes []byte) ([]byte, []string) {
|
||||
once.Do(func() {
|
||||
gdMarkdown := goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table,
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
extension.DefinitionList,
|
||||
common.FootnoteExtension,
|
||||
common.Linkify,
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAttribute(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithUnsafe(),
|
||||
),
|
||||
)
|
||||
stripParser = gdMarkdown.Parser()
|
||||
})
|
||||
stripper := &stripRenderer{
|
||||
localhost: getGiteaHost(),
|
||||
links: make([]string, 0, 10),
|
||||
empty: true,
|
||||
}
|
||||
reader := text.NewReader(rawBytes)
|
||||
doc := stripParser.Parse(reader)
|
||||
var buf bytes.Buffer
|
||||
if err := stripper.Render(&buf, rawBytes, doc); err != nil {
|
||||
log.Error("Unable to strip: %v", err)
|
||||
}
|
||||
return buf.Bytes(), stripper.GetLinks()
|
||||
}
|
||||
|
||||
// getGiteaHostName returns a normalized string with the local host name, with no scheme or port information
|
||||
func getGiteaHost() *url.URL {
|
||||
giteaHostInit.Do(func() {
|
||||
var err error
|
||||
if giteaHost, err = url.Parse(setting.AppURL); err != nil {
|
||||
giteaHost = &url.URL{}
|
||||
}
|
||||
})
|
||||
return giteaHost
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mdstripper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMarkdownStripper(t *testing.T) {
|
||||
type testItem struct {
|
||||
markdown string
|
||||
expectedText []string
|
||||
expectedLinks []string
|
||||
}
|
||||
|
||||
list := []testItem{
|
||||
{
|
||||
`
|
||||
## This is a title
|
||||
|
||||
This is [one](link) to paradise.
|
||||
This **is emphasized**.
|
||||
This: should coalesce.
|
||||
|
||||
` + "```" + `
|
||||
This is a code block.
|
||||
This should not appear in the output at all.
|
||||
` + "```" + `
|
||||
|
||||
* Bullet 1
|
||||
* Bullet 2
|
||||
|
||||
A HIDDEN ` + "`" + `GHOST` + "`" + ` IN THIS LINE.
|
||||
`,
|
||||
[]string{
|
||||
"This is a title",
|
||||
"This is",
|
||||
"to paradise.",
|
||||
"This",
|
||||
"is emphasized",
|
||||
".",
|
||||
"This: should coalesce.",
|
||||
"Bullet 1",
|
||||
"Bullet 2",
|
||||
"A HIDDEN",
|
||||
"IN THIS LINE.",
|
||||
},
|
||||
[]string{
|
||||
"link",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Simply closes: #29 yes",
|
||||
[]string{
|
||||
"Simply closes: #29 yes",
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"Simply closes: !29 yes",
|
||||
[]string{
|
||||
"Simply closes: !29 yes",
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range list {
|
||||
text, links := StripMarkdown([]byte(test.markdown))
|
||||
rawlines := strings.Split(text, "\n")
|
||||
lines := make([]string, 0, len(rawlines))
|
||||
for _, line := range rawlines {
|
||||
line := strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, test.expectedText, lines)
|
||||
assert.Equal(t, test.expectedLinks, links)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package orgmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/highlight"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/niklasfasching/go-org/org"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(renderer{})
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for orgmode
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.PostProcessRenderer = (*renderer)(nil)
|
||||
)
|
||||
|
||||
func (renderer) Name() string {
|
||||
return "orgmode"
|
||||
}
|
||||
|
||||
func (renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (renderer) FileNamePatterns() []string {
|
||||
return []string{"*.org"}
|
||||
}
|
||||
|
||||
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{}
|
||||
}
|
||||
|
||||
// Render renders orgmode raw bytes to HTML
|
||||
func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
htmlWriter := org.NewHTMLWriter()
|
||||
htmlWriter.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// catch the panic, log the error and return empty result
|
||||
log.Error("Panic in HighlightCodeBlock: %v\n%s", err, log.Stack(2))
|
||||
}
|
||||
}()
|
||||
|
||||
lexer := highlight.DetectChromaLexerByFileName("", lang) // don't use content to detect, it is too slow
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
sb := &strings.Builder{}
|
||||
// include language-x class as part of commonmark spec
|
||||
_ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `<pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
|
||||
_, _ = sb.WriteString(string(highlight.RenderCodeByLexer(lexer, source)))
|
||||
_, _ = sb.WriteString("</code></pre>")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
|
||||
htmlWriter.ExtendingWriter = w
|
||||
|
||||
res, err := org.New().Silent().Parse(input, "").Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
_, err = io.Copy(output, strings.NewReader(res))
|
||||
return err
|
||||
}
|
||||
|
||||
// RenderString renders orgmode string to HTML string
|
||||
func RenderString(ctx *markup.RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// Render renders orgmode string to HTML string
|
||||
func (renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
return Render(ctx, input, output)
|
||||
}
|
||||
|
||||
type orgWriter struct {
|
||||
*org.HTMLWriter
|
||||
rctx *markup.RenderContext
|
||||
}
|
||||
|
||||
var _ org.Writer = (*orgWriter)(nil)
|
||||
|
||||
func (r *orgWriter) resolveLink(link string) string {
|
||||
return strings.TrimPrefix(link, "file:")
|
||||
}
|
||||
|
||||
// WriteRegularLink renders images, links or videos
|
||||
func (r *orgWriter) WriteRegularLink(l org.RegularLink) {
|
||||
link := r.resolveLink(l.URL)
|
||||
// Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
|
||||
switch l.Kind() {
|
||||
case "image":
|
||||
if l.Description == nil {
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<img src="%s" alt="%s">`, link, link)
|
||||
} else {
|
||||
imageSrc := r.resolveLink(org.String(l.Description...))
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
|
||||
}
|
||||
case "video":
|
||||
if l.Description == nil {
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<video src="%s">%s</video>`, link, link)
|
||||
} else {
|
||||
videoSrc := r.resolveLink(org.String(l.Description...))
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
|
||||
}
|
||||
default:
|
||||
var description any = link
|
||||
if l.Description != nil {
|
||||
description = template.HTML(r.WriteNodesAsString(l.Description...)) // orgmode HTMLWriter outputs HTML content
|
||||
}
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s">%s</a>`, link, description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package orgmode_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/orgmode"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
setting.AppURL = "http://localhost:3000/"
|
||||
setting.IsInTesting = true
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_InternalLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[./test.org][Test]]",
|
||||
`<p><a href="./test.org">Test</a></p>`)
|
||||
test("[[test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[path/to/test.org][Test]]",
|
||||
`<p><a href="path/to/test.org">Test</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
`<p><img src="train.jpg" alt="train.jpg"></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
|
||||
test("[[https://example.com][https://example.com/example.mp4]]",
|
||||
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
|
||||
|
||||
// Without description.
|
||||
test("[[https://example.com/example.svg]]",
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(`#+begin_src c
|
||||
int a;
|
||||
#+end_src
|
||||
`, `<div class="src src-c">
|
||||
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
|
||||
</div>`)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type RenderMetaMode string
|
||||
|
||||
const (
|
||||
RenderMetaAsDetails RenderMetaMode = "details" // default
|
||||
RenderMetaAsNone RenderMetaMode = "none"
|
||||
RenderMetaAsTable RenderMetaMode = "table"
|
||||
)
|
||||
|
||||
var RenderBehaviorForTesting struct {
|
||||
// Gitea will emit some additional attributes for various purposes, these attributes don't affect rendering.
|
||||
// But there are too many hard-coded test cases, to avoid changing all of them again and again, we can disable emitting these internal attributes.
|
||||
DisableAdditionalAttributes bool
|
||||
}
|
||||
|
||||
type WebThemeInterface interface {
|
||||
PublicAssetURI() string
|
||||
}
|
||||
|
||||
type StandalonePageOptions struct {
|
||||
CurrentWebTheme WebThemeInterface
|
||||
RenderQueryString string
|
||||
}
|
||||
|
||||
type RenderOptions struct {
|
||||
UseAbsoluteLink bool
|
||||
|
||||
// relative path from tree root of the branch
|
||||
RelativePath string
|
||||
|
||||
// eg: "orgmode", "asciicast", "console"
|
||||
// for file mode, it could be left as empty, and will be detected by file extension in RelativePath
|
||||
MarkupType string
|
||||
|
||||
// user&repo, format&style®exp (for external issue pattern), teams&org (for mention)
|
||||
// RefTypeNameSubURL (for iframe&asciicast)
|
||||
// markupAllowShortIssuePattern
|
||||
// markdownNewLineHardBreak
|
||||
Metas map[string]string
|
||||
|
||||
// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
|
||||
StandalonePageOptions *StandalonePageOptions
|
||||
|
||||
// EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute.
|
||||
// This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs.
|
||||
EnableHeadingIDGeneration bool
|
||||
}
|
||||
|
||||
type TocShowInSectionType string
|
||||
|
||||
const (
|
||||
TocShowInSidebar TocShowInSectionType = "sidebar"
|
||||
TocShowInMain TocShowInSectionType = "main"
|
||||
)
|
||||
|
||||
type TocHeadingItem struct {
|
||||
HeadingLevel int
|
||||
AnchorID string
|
||||
InnerText string
|
||||
}
|
||||
|
||||
// RenderContext represents a render context
|
||||
type RenderContext struct {
|
||||
ctx context.Context
|
||||
|
||||
// the context might be used by the "render" function, but it might also be used by "postProcess" function
|
||||
usedByRender bool
|
||||
|
||||
TocShowInSection TocShowInSectionType
|
||||
TocHeadingItems []*TocHeadingItem
|
||||
|
||||
RenderHelper RenderHelper
|
||||
RenderOptions RenderOptions
|
||||
RenderInternal internal.RenderInternal
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) Deadline() (deadline time.Time, ok bool) {
|
||||
return ctx.ctx.Deadline()
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) Done() <-chan struct{} {
|
||||
return ctx.ctx.Done()
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) Err() error {
|
||||
return ctx.ctx.Err()
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) Value(key any) any {
|
||||
return ctx.ctx.Value(key)
|
||||
}
|
||||
|
||||
var _ context.Context = (*RenderContext)(nil)
|
||||
|
||||
func NewRenderContext(ctx context.Context) *RenderContext {
|
||||
return &RenderContext{ctx: ctx, RenderHelper: &SimpleRenderHelper{}}
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithMarkupType(typ string) *RenderContext {
|
||||
ctx.RenderOptions.MarkupType = typ
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithRelativePath(path string) *RenderContext {
|
||||
ctx.RenderOptions.RelativePath = path
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext {
|
||||
ctx.RenderOptions.Metas = metas
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext {
|
||||
ctx.RenderOptions.StandalonePageOptions = &opts
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext {
|
||||
ctx.RenderOptions.EnableHeadingIDGeneration = v
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext {
|
||||
ctx.RenderOptions.UseAbsoluteLink = v
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext {
|
||||
ctx.RenderHelper = helper
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) DetectMarkupRenderer(prefetchBuf []byte) Renderer {
|
||||
if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" {
|
||||
var sniffedType typesniffer.SniffedType
|
||||
if len(prefetchBuf) > 0 {
|
||||
sniffedType = typesniffer.DetectContentType(prefetchBuf)
|
||||
}
|
||||
ctx.RenderOptions.MarkupType = DetectRendererTypeByPrefetch(ctx.RenderOptions.RelativePath, sniffedType, prefetchBuf)
|
||||
}
|
||||
return renderers[ctx.RenderOptions.MarkupType]
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) DetectMarkupRendererByReader(in io.Reader) (Renderer, io.Reader, error) {
|
||||
prefetchBuf := make([]byte, 512)
|
||||
n, err := util.ReadAtMost(in, prefetchBuf)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, nil, err
|
||||
}
|
||||
prefetchBuf = prefetchBuf[:n]
|
||||
renderer := ctx.DetectMarkupRenderer(prefetchBuf)
|
||||
if renderer == nil {
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("unable to find a render")
|
||||
}
|
||||
return renderer, io.MultiReader(bytes.NewReader(prefetchBuf), in), nil
|
||||
}
|
||||
|
||||
func RendererNeedPostProcess(renderer Renderer) bool {
|
||||
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Render renders markup file to HTML with all specific handling stuff.
|
||||
func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
|
||||
renderer, input, err := rctx.DetectMarkupRendererByReader(origInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return RenderWithRenderer(rctx, renderer, input, output)
|
||||
}
|
||||
|
||||
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
|
||||
ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
|
||||
refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
|
||||
if ownerName == "" || repoName == "" || refSubURL == "" {
|
||||
setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
|
||||
return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
|
||||
}
|
||||
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
|
||||
url.PathEscape(ownerName),
|
||||
url.PathEscape(repoName),
|
||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
||||
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
|
||||
)
|
||||
var extraAttrs template.HTML
|
||||
if opts.ContentSandbox != "" {
|
||||
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
|
||||
}
|
||||
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
|
||||
return err
|
||||
}
|
||||
|
||||
func pipes() (io.ReadCloser, io.WriteCloser, func()) {
|
||||
pr, pw := io.Pipe()
|
||||
return pr, pw, func() {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
|
||||
if externalRender, ok := renderer.(ExternalRenderer); ok {
|
||||
return externalRender.GetExternalRendererOptions(), true
|
||||
}
|
||||
return ret, false
|
||||
}
|
||||
|
||||
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
|
||||
var extraHeadHTML template.HTML
|
||||
if extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
|
||||
if ctx.RenderOptions.StandalonePageOptions == nil {
|
||||
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
|
||||
// otherwise, a <iframe> should be outputted to embed the external rendered page
|
||||
return RenderIFrame(ctx, &extOpts, output)
|
||||
}
|
||||
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
|
||||
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
|
||||
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
|
||||
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
|
||||
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
|
||||
extraHeadHTML = htmlutil.HTMLFormat(
|
||||
`<script nonce crossorigin src="%s" id="gitea-external-render-helper" data-render-query-string="%s"></script>`+
|
||||
`<link rel="stylesheet" href="%s">`,
|
||||
extraScriptSrc, ctx.RenderOptions.StandalonePageOptions.RenderQueryString,
|
||||
extraLinkHref,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.usedByRender = true
|
||||
if ctx.RenderHelper != nil {
|
||||
defer ctx.RenderHelper.CleanUp()
|
||||
}
|
||||
|
||||
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
|
||||
defer finalProcessor.Close()
|
||||
|
||||
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
|
||||
// no sanitizer: input -> (pw1=pr1) -> renderer -> pw2(finalProcessor) -> output
|
||||
pr1, pw1, close1 := pipes()
|
||||
defer close1()
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
|
||||
|
||||
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
|
||||
var pr2 io.ReadCloser
|
||||
var close2 func()
|
||||
pr2, pw2, close2 = pipes()
|
||||
defer close2()
|
||||
eg.Go(func() error {
|
||||
defer pr2.Close()
|
||||
return SanitizeReader(pr2, renderer.Name(), finalProcessor)
|
||||
})
|
||||
}
|
||||
|
||||
eg.Go(func() (err error) {
|
||||
if RendererNeedPostProcess(renderer) {
|
||||
err = PostProcessDefault(ctx, pr1, pw2)
|
||||
} else {
|
||||
_, err = io.Copy(pw2, pr1)
|
||||
}
|
||||
_, _ = pr1.Close(), pw2.Close()
|
||||
return err
|
||||
})
|
||||
|
||||
if err := renderer.Render(ctx, input, pw1); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = pw1.Close()
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
// Init initializes the render global variables
|
||||
func Init(renderHelpFuncs *RenderHelperFuncs) {
|
||||
DefaultRenderHelperFuncs = renderHelpFuncs
|
||||
if len(setting.Markdown.CustomURLSchemes) > 0 {
|
||||
CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
||||
}
|
||||
|
||||
// since setting maybe changed extensions, this will reload all renderer extensions mapping
|
||||
fileNameRenderers = make(map[string]Renderer)
|
||||
for _, renderer := range renderers {
|
||||
for _, pattern := range renderer.FileNamePatterns() {
|
||||
fileNameRenderers[pattern] = renderer
|
||||
}
|
||||
}
|
||||
|
||||
RefreshFileNamePatterns()
|
||||
}
|
||||
|
||||
func ComposeSimpleDocumentMetas() map[string]string {
|
||||
// TODO: there is no separate config option for "simple document" rendering, so temporarily use the same config as "repo file"
|
||||
return map[string]string{"markdownNewLineHardBreak": strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.NewLineHardBreak)}
|
||||
}
|
||||
|
||||
type TestRenderHelper struct {
|
||||
ctx *RenderContext
|
||||
BaseLink string
|
||||
}
|
||||
|
||||
func (r *TestRenderHelper) CleanUp() {}
|
||||
|
||||
func (r *TestRenderHelper) IsCommitIDExisting(commitID string) bool {
|
||||
return strings.HasPrefix(commitID, "65f1bf2") //|| strings.HasPrefix(commitID, "88fc37a")
|
||||
}
|
||||
|
||||
func (r *TestRenderHelper) ResolveLink(link, preferLinkType string) string {
|
||||
linkType, link := ParseRenderedLink(link, preferLinkType)
|
||||
switch linkType {
|
||||
case LinkTypeRoot:
|
||||
return r.ctx.ResolveLinkRoot(link)
|
||||
default:
|
||||
return r.ctx.ResolveLinkRelative(r.BaseLink, "", link)
|
||||
}
|
||||
}
|
||||
|
||||
var _ RenderHelper = (*TestRenderHelper)(nil)
|
||||
|
||||
// NewTestRenderContext is a helper function to create a RenderContext for testing purpose
|
||||
// It accepts string (BaseLink), map[string]string (Metas)
|
||||
func NewTestRenderContext(baseLinkOrMetas ...any) *RenderContext {
|
||||
if !setting.IsInTesting {
|
||||
panic("NewTestRenderContext should only be used in testing")
|
||||
}
|
||||
helper := &TestRenderHelper{}
|
||||
ctx := NewRenderContext(context.Background()).WithHelper(helper)
|
||||
helper.ctx = ctx
|
||||
for _, v := range baseLinkOrMetas {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
helper.BaseLink = v
|
||||
case map[string]string:
|
||||
ctx = ctx.WithMetas(v)
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown type %T", v))
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
LinkTypeDefault = ""
|
||||
LinkTypeRoot = "/:root" // the link is relative to the AppSubURL(ROOT_URL)
|
||||
LinkTypeMedia = "/:media" // the link should be used to access media files (images, videos)
|
||||
LinkTypeRaw = "/:raw" // not really useful, mainly for environment GITEA_PREFIX_RAW for external renders
|
||||
)
|
||||
|
||||
type RenderHelper interface {
|
||||
CleanUp()
|
||||
|
||||
// TODO: such dependency is not ideal. We should decouple the processors step by step.
|
||||
// It should make the render choose different processors for different purposes,
|
||||
// but not make processors to guess "is it rendering a comment or a wiki?" or "does it need to check commit ID?"
|
||||
|
||||
IsCommitIDExisting(commitID string) bool
|
||||
ResolveLink(link, preferLinkType string) string
|
||||
}
|
||||
|
||||
// RenderHelperFuncs is used to decouple cycle-import
|
||||
// At the moment there are different packages:
|
||||
// modules/markup: basic markup rendering
|
||||
// models/renderhelper: need to access models and git repo, and models/issues needs it
|
||||
// services/markup: some real helper functions could only be provided here because it needs to access various services & templates
|
||||
type RenderHelperFuncs struct {
|
||||
IsUsernameMentionable func(ctx context.Context, username string) bool
|
||||
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
|
||||
RenderRepoIssueIconTitle func(ctx context.Context, options RenderIssueIconTitleOptions) (template.HTML, error)
|
||||
}
|
||||
|
||||
var DefaultRenderHelperFuncs *RenderHelperFuncs
|
||||
|
||||
type SimpleRenderHelper struct{}
|
||||
|
||||
func (r *SimpleRenderHelper) CleanUp() {}
|
||||
|
||||
func (r *SimpleRenderHelper) IsCommitIDExisting(commitID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *SimpleRenderHelper) ResolveLink(link, preferLinkType string) string {
|
||||
_, link = ParseRenderedLink(link, preferLinkType)
|
||||
return resolveLinkRelative(context.Background(), setting.AppSubURL+"/", "", link, false)
|
||||
}
|
||||
|
||||
var _ RenderHelper = (*SimpleRenderHelper)(nil)
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// resolveLinkRelative tries to resolve the link relative to the "{base}/{cur}", and returns the final link.
|
||||
// It only resolves the link, doesn't do any sanitization or validation, invalid links will be returned as is.
|
||||
func resolveLinkRelative(ctx context.Context, base, cur, link string, absolute bool) (finalLink string) {
|
||||
linkURL, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return link // invalid URL, return as is
|
||||
}
|
||||
if linkURL.Scheme != "" || linkURL.Host != "" {
|
||||
return link // absolute URL, return as is
|
||||
}
|
||||
|
||||
if strings.HasPrefix(link, "/") {
|
||||
if strings.HasPrefix(link, base) && strings.Count(base, "/") >= 4 {
|
||||
// a trick to tolerate that some users were using absolute paths (the old Gitea's behavior)
|
||||
// if the link is likely "{base}/src/main" while "{base}" is something like "/owner/repo"
|
||||
finalLink = link
|
||||
} else {
|
||||
// need to resolve the link relative to "{base}"
|
||||
cur = ""
|
||||
}
|
||||
} // else: link is relative to "{base}/{cur}"
|
||||
|
||||
if finalLink == "" {
|
||||
finalLink = strings.TrimSuffix(base, "/") + path.Join("/"+cur, "/"+linkURL.EscapedPath())
|
||||
finalLink = strings.TrimSuffix(finalLink, "/")
|
||||
if linkURL.RawQuery != "" {
|
||||
finalLink += "?" + linkURL.RawQuery
|
||||
}
|
||||
if linkURL.Fragment != "" {
|
||||
finalLink += "#" + linkURL.Fragment
|
||||
}
|
||||
}
|
||||
|
||||
if absolute {
|
||||
finalLink = httplib.MakeAbsoluteURL(ctx, finalLink)
|
||||
}
|
||||
return finalLink
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) ResolveLinkRelative(base, cur, link string) string {
|
||||
if strings.HasPrefix(link, "/:") {
|
||||
setting.PanicInDevOrTesting("invalid link %q, forgot to cut?", link)
|
||||
}
|
||||
return resolveLinkRelative(ctx, base, cur, link, ctx.RenderOptions.UseAbsoluteLink)
|
||||
}
|
||||
|
||||
func (ctx *RenderContext) ResolveLinkRoot(link string) string {
|
||||
return ctx.ResolveLinkRelative(setting.AppSubURL+"/", "", link)
|
||||
}
|
||||
|
||||
func ParseRenderedLink(s, preferLinkType string) (linkType, link string) {
|
||||
if strings.HasPrefix(s, "/:") {
|
||||
p := strings.IndexByte(s[1:], '/')
|
||||
if p == -1 {
|
||||
return s, ""
|
||||
}
|
||||
return s[:p+1], s[p+2:]
|
||||
}
|
||||
return preferLinkType, s
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveLinkRelative(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
setting.AppURL = "http://localhost:3000"
|
||||
assert.Equal(t, "/a", resolveLinkRelative(ctx, "/a", "", "", false))
|
||||
assert.Equal(t, "/a/b", resolveLinkRelative(ctx, "/a", "b", "", false))
|
||||
assert.Equal(t, "/a/b/c", resolveLinkRelative(ctx, "/a", "b", "c", false))
|
||||
assert.Equal(t, "/a/c", resolveLinkRelative(ctx, "/a", "b", "/c", false))
|
||||
assert.Equal(t, "/a/c#id", resolveLinkRelative(ctx, "/a", "b", "/c#id", false))
|
||||
assert.Equal(t, "/a/%2f?k=/", resolveLinkRelative(ctx, "/a", "b", "/%2f/?k=/", false))
|
||||
assert.Equal(t, "/a/b/c?k=v#id", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#id", false))
|
||||
assert.Equal(t, "%invalid", resolveLinkRelative(ctx, "/a", "b", "%invalid", false))
|
||||
assert.Equal(t, "http://localhost:3000/a", resolveLinkRelative(ctx, "/a", "", "", true))
|
||||
|
||||
// absolute link is returned as is
|
||||
assert.Equal(t, "mailto:user@domain.com", resolveLinkRelative(ctx, "/a", "", "mailto:user@domain.com", false))
|
||||
assert.Equal(t, "http://other/path/", resolveLinkRelative(ctx, "/a", "", "http://other/path/", false))
|
||||
|
||||
// some users might have used absolute paths a lot, so if the prefix overlaps and has enough slashes, we should tolerate it
|
||||
assert.Equal(t, "/owner/repo/foo/owner/repo/foo/bar/xxx", resolveLinkRelative(ctx, "/owner/repo/foo", "", "/owner/repo/foo/bar/xxx", false))
|
||||
assert.Equal(t, "/owner/repo/foo/bar/xxx", resolveLinkRelative(ctx, "/owner/repo/foo/bar", "", "/owner/repo/foo/bar/xxx", false))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderIFrame(t *testing.T) {
|
||||
render := func(ctx *RenderContext, opts ExternalRendererOptions) string {
|
||||
sb := &strings.Builder{}
|
||||
require.NoError(t, RenderIFrame(ctx, &opts, sb))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
ctx := NewRenderContext(t.Context()).
|
||||
WithRelativePath("tree-path").
|
||||
WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"})
|
||||
|
||||
// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
|
||||
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
|
||||
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, ret)
|
||||
|
||||
ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
|
||||
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
)
|
||||
|
||||
// Renderer defines an interface for rendering markup file to HTML
|
||||
type Renderer interface {
|
||||
Name() string // markup format name, also the renderer type, also the external tool name
|
||||
FileNamePatterns() []string
|
||||
SanitizerRules() []setting.MarkupSanitizerRule
|
||||
Render(ctx *RenderContext, input io.Reader, output io.Writer) error
|
||||
}
|
||||
|
||||
// PostProcessRenderer defines an interface for renderers who need post process
|
||||
type PostProcessRenderer interface {
|
||||
NeedPostProcess() bool
|
||||
}
|
||||
|
||||
type ExternalRendererOptions struct {
|
||||
SanitizerDisabled bool
|
||||
DisplayInIframe bool
|
||||
ContentSandbox string
|
||||
}
|
||||
|
||||
// ExternalRenderer defines an interface for external renderers
|
||||
type ExternalRenderer interface {
|
||||
GetExternalRendererOptions() ExternalRendererOptions
|
||||
}
|
||||
|
||||
// RendererContentDetector detects if the content can be rendered
|
||||
// by specified renderer
|
||||
type RendererContentDetector interface {
|
||||
CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool
|
||||
}
|
||||
|
||||
var (
|
||||
fileNameRenderers = make(map[string]Renderer)
|
||||
renderers = make(map[string]Renderer)
|
||||
)
|
||||
|
||||
// RegisterRenderer registers a new markup file renderer
|
||||
func RegisterRenderer(renderer Renderer) {
|
||||
// TODO: need to handle conflicts
|
||||
renderers[renderer.Name()] = renderer
|
||||
}
|
||||
|
||||
func RefreshFileNamePatterns() {
|
||||
// TODO: need to handle conflicts
|
||||
fileNameRenderers = make(map[string]Renderer)
|
||||
for _, renderer := range renderers {
|
||||
for _, ext := range renderer.FileNamePatterns() {
|
||||
fileNameRenderers[strings.ToLower(ext)] = renderer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DetectRendererTypeByFilename(filename string) Renderer {
|
||||
basename := path.Base(strings.ToLower(filename))
|
||||
ext1 := path.Ext(basename)
|
||||
if renderer := fileNameRenderers[basename]; renderer != nil {
|
||||
return renderer
|
||||
}
|
||||
if renderer := fileNameRenderers["*"+ext1]; renderer != nil {
|
||||
return renderer
|
||||
}
|
||||
if basename, ok := strings.CutSuffix(basename, ext1); ok {
|
||||
ext2 := path.Ext(basename)
|
||||
if renderer := fileNameRenderers["*"+ext2+ext1]; renderer != nil {
|
||||
return renderer
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectRendererTypeByPrefetch detects the markup type of the content
|
||||
func DetectRendererTypeByPrefetch(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string {
|
||||
if filename != "" {
|
||||
byExt := DetectRendererTypeByFilename(filename)
|
||||
if byExt != nil {
|
||||
return byExt.Name()
|
||||
}
|
||||
}
|
||||
for _, renderer := range renderers {
|
||||
if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, sniffedType, prefetchBuf) {
|
||||
return renderer.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func PreviewableExtensions() []string {
|
||||
exts := make([]string, 0, len(fileNameRenderers))
|
||||
for p := range fileNameRenderers {
|
||||
if s, ok := strings.CutPrefix(p, "*"); ok {
|
||||
exts = append(exts, s)
|
||||
}
|
||||
}
|
||||
return exts
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
// Sanitizer is a protection wrapper of *bluemonday.Policy which does not allow
|
||||
// any modification to the underlying policies once it's been created.
|
||||
type Sanitizer struct {
|
||||
defaultPolicy *bluemonday.Policy
|
||||
descriptionPolicy *bluemonday.Policy
|
||||
rendererPolicies map[string]*bluemonday.Policy
|
||||
allowAllRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
var (
|
||||
defaultSanitizer *Sanitizer
|
||||
defaultSanitizerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetDefaultSanitizer() *Sanitizer {
|
||||
defaultSanitizerOnce.Do(func() {
|
||||
defaultSanitizer = &Sanitizer{
|
||||
rendererPolicies: map[string]*bluemonday.Policy{},
|
||||
allowAllRegex: regexp.MustCompile(".+"),
|
||||
}
|
||||
for name, renderer := range renderers {
|
||||
sanitizerRules := renderer.SanitizerRules()
|
||||
if len(sanitizerRules) > 0 {
|
||||
policy := defaultSanitizer.createDefaultPolicy()
|
||||
defaultSanitizer.addSanitizerRules(policy, sanitizerRules)
|
||||
defaultSanitizer.rendererPolicies[name] = policy
|
||||
}
|
||||
}
|
||||
defaultSanitizer.defaultPolicy = defaultSanitizer.createDefaultPolicy()
|
||||
defaultSanitizer.descriptionPolicy = defaultSanitizer.createRepoDescriptionPolicy()
|
||||
})
|
||||
return defaultSanitizer
|
||||
}
|
||||
|
||||
func ResetDefaultSanitizerForTesting() {
|
||||
defaultSanitizer = nil
|
||||
defaultSanitizerOnce = sync.Once{}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
func (st *Sanitizer) addSanitizerRules(policy *bluemonday.Policy, rules []setting.MarkupSanitizerRule) {
|
||||
for _, rule := range rules {
|
||||
if rule.AllowDataURIImages {
|
||||
policy.AllowDataURIImages()
|
||||
}
|
||||
if rule.Element != "" {
|
||||
if rule.Regexp != "" {
|
||||
if !strings.HasPrefix(rule.Regexp, "^") || !strings.HasSuffix(rule.Regexp, "$") {
|
||||
panic("Markup sanitizer rule regexp must start with ^ and end with $ to be strict")
|
||||
}
|
||||
policy.AllowAttrs(rule.AllowAttr).Matching(regexp.MustCompile(rule.Regexp)).OnElements(rule.Element)
|
||||
} else {
|
||||
policy.AllowAttrs(rule.AllowAttr).OnElements(rule.Element)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
|
||||
// NOTICE: DO NOT add special "class" regexp rules here anymore, use RenderInternal.SafeAttr instead
|
||||
|
||||
// General safe SVG attributes
|
||||
policy.AllowAttrs("viewBox", "width", "height", "aria-hidden", "data-attr-class").OnElements("svg")
|
||||
policy.AllowAttrs("fill-rule", "d").OnElements("path")
|
||||
|
||||
// Checkboxes
|
||||
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
|
||||
policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
|
||||
|
||||
// Chroma always uses 1-2 letters for style names, we could tolerate it at the moment
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^\w{0,2}$`)).OnElements("span")
|
||||
|
||||
// Line numbers on codepreview
|
||||
policy.AllowAttrs("data-line-number").OnElements("span")
|
||||
|
||||
// Custom URL-Schemes
|
||||
if len(setting.Markdown.CustomURLSchemes) > 0 {
|
||||
policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...)
|
||||
} else {
|
||||
policy.AllowURLSchemesMatching(st.allowAllRegex)
|
||||
|
||||
// Even if every scheme is allowed, these three are blocked for security reasons
|
||||
disallowScheme := func(*url.URL) bool {
|
||||
return false
|
||||
}
|
||||
policy.AllowURLSchemeWithCustomPolicy("javascript", disallowScheme)
|
||||
policy.AllowURLSchemeWithCustomPolicy("vbscript", disallowScheme)
|
||||
policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme)
|
||||
}
|
||||
|
||||
// Allow classes for org mode list item status.
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li")
|
||||
|
||||
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
|
||||
policy.AllowStyles("color", "background-color").OnElements("div", "span", "p", "tr", "th", "td")
|
||||
|
||||
policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
|
||||
|
||||
// Native support of "<picture><source media=... srcset=...><img src=...></picture>"
|
||||
// ATTENTION: it only works with "auto" theme, because "media" query doesn't work with the theme chosen by end user manually.
|
||||
// For example: browser's color scheme is "dark", but end user chooses "light" theme. Maybe it needs JS to help to make it work.
|
||||
policy.AllowAttrs("media", "srcset").OnElements("source")
|
||||
|
||||
policy.AllowAttrs("loading").OnElements("img")
|
||||
|
||||
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
|
||||
generalSafeAttrs := []string{
|
||||
"abbr", "accept", "accept-charset",
|
||||
"accesskey", "action", "align", "alt",
|
||||
"aria-describedby", "aria-hidden", "aria-label", "aria-labelledby",
|
||||
"axis", "border", "cellpadding", "cellspacing", "char",
|
||||
"charoff", "charset", "checked",
|
||||
"clear", "cols", "colspan", "color",
|
||||
"compact", "coords", "datetime", "dir",
|
||||
"disabled", "enctype", "for", "frame",
|
||||
"headers", "height", "hreflang",
|
||||
"hspace", "ismap", "label", "lang",
|
||||
"maxlength", "media", "method",
|
||||
"multiple", "name", "nohref", "noshade",
|
||||
"nowrap", "open", "prompt", "readonly", "rel", "rev",
|
||||
"rows", "rowspan", "rules", "scope",
|
||||
"selected", "shape", "size", "span",
|
||||
"start", "summary", "tabindex", "target",
|
||||
"title", "type", "usemap", "valign", "value",
|
||||
"vspace", "width", "itemprop", "itemscope", "itemtype",
|
||||
"data-markdown-generated-content", "data-attr-class",
|
||||
}
|
||||
generalSafeElements := []string{
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "center", "i", "strong", "em", "a", "pre", "code", "img", "tt",
|
||||
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
|
||||
"dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
|
||||
"details", "caption", "figure", "figcaption",
|
||||
"abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
|
||||
"picture", "source",
|
||||
}
|
||||
// FIXME: Need to handle longdesc in img but there is no easy way to do it
|
||||
policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
|
||||
|
||||
// Custom keyword markup
|
||||
defaultSanitizer.addSanitizerRules(policy, setting.ExternalSanitizerRules)
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
// Sanitize use default sanitizer policy to sanitize a string
|
||||
func Sanitize(s string) template.HTML {
|
||||
return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(s))
|
||||
}
|
||||
|
||||
// SanitizeReader sanitizes a Reader
|
||||
func SanitizeReader(r io.Reader, renderer string, w io.Writer) error {
|
||||
policy, exist := GetDefaultSanitizer().rendererPolicies[renderer]
|
||||
if !exist {
|
||||
policy = GetDefaultSanitizer().defaultPolicy
|
||||
}
|
||||
return policy.SanitizeReaderToWriter(r, w)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2017 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSanitizer(t *testing.T) {
|
||||
testCases := []string{
|
||||
// Regular
|
||||
`<a onblur="alert(secret)" href="http://www.google.com">Google</a>`, `<a href="http://www.google.com" rel="nofollow">Google</a>`,
|
||||
"<scrİpt><script>alert(document.domain)</script></scrİpt>", "<script>alert(document.domain)</script>",
|
||||
|
||||
// Code highlighting class
|
||||
`<code class="random string"></code>`, `<code></code>`,
|
||||
`<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`,
|
||||
`<span class="k"></span><span class="nb"></span>`, `<span class="k"></span><span class="nb"></span>`,
|
||||
|
||||
// Input checkbox
|
||||
`<input type="hidden">`, ``,
|
||||
`<input type="checkbox">`, `<input type="checkbox">`,
|
||||
`<input checked disabled autofocus>`, `<input checked="" disabled="">`,
|
||||
|
||||
// Code highlight injection
|
||||
`<code class="language-random ui tab active menu attached animating sidebar following bar center"></code>`, `<code></code>`,
|
||||
`<code class="language-lol ui tab active menu attached animating sidebar following bar center">
|
||||
<code class="language-lol ui container input huge basic segment center"> </code>
|
||||
<img src="https://try.gogs.io/img/favicon.png" width="200" height="200">
|
||||
<code class="language-lol ui container input massive basic segment">Hello there! Something has gone wrong, we are working on it.</code>
|
||||
<code class="language-lol ui container input huge basic segment">In the meantime, play a game with us at <a href="http://example.com/">example.com</a>.</code>
|
||||
</code>`, "<code>\n<code>\u00a0</code>\n<img src=\"https://try.gogs.io/img/favicon.png\" width=\"200\" height=\"200\">\n<code>Hello there! Something has gone wrong, we are working on it.</code>\n<code>In the meantime, play a game with us at\u00a0<a href=\"http://example.com/\" rel=\"nofollow\">example.com</a>.</code>\n</code>",
|
||||
|
||||
// <kbd> tags
|
||||
`<kbd>Ctrl + C</kbd>`, `<kbd>Ctrl + C</kbd>`,
|
||||
`<i class="dropdown icon">NAUGHTY</i>`, `<i>NAUGHTY</i>`,
|
||||
`<input type="checkbox" disabled=""/>unchecked`, `<input type="checkbox" disabled=""/>unchecked`,
|
||||
`<span class="emoji dropdown">NAUGHTY</span>`, `<span>NAUGHTY</span>`,
|
||||
|
||||
// Color property
|
||||
`<span style="color: red">Hello World</span>`, `<span style="color: red">Hello World</span>`,
|
||||
`<p style="color: red">Hello World</p>`, `<p style="color: red">Hello World</p>`,
|
||||
`<code style="color: red">Hello World</code>`, `<code>Hello World</code>`,
|
||||
`<span style="bad-color: red">Hello World</span>`, `<span>Hello World</span>`,
|
||||
`<p style="bad-color: red">Hello World</p>`, `<p>Hello World</p>`,
|
||||
`<code style="bad-color: red">Hello World</code>`, `<code>Hello World</code>`,
|
||||
|
||||
// Org mode status of list items.
|
||||
`<li class="checked"></li>`, `<li class="checked"></li>`,
|
||||
`<li class="unchecked"></li>`, `<li class="unchecked"></li>`,
|
||||
`<li class="indeterminate"></li>`, `<li class="indeterminate"></li>`,
|
||||
|
||||
// URLs
|
||||
`<a href="cbthunderlink://somebase64string)">my custom URL scheme</a>`, `<a href="cbthunderlink://somebase64string)" rel="nofollow">my custom URL scheme</a>`,
|
||||
`<a href="matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join">my custom URL scheme</a>`, `<a href="matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join" rel="nofollow">my custom URL scheme</a>`,
|
||||
|
||||
// picture
|
||||
`<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`, `<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`,
|
||||
|
||||
// Disallow dangerous url schemes
|
||||
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
|
||||
`<a href="vbscript:no">bad</a>`, `bad`,
|
||||
`<a href="data:1234">bad</a>`, `bad`,
|
||||
|
||||
// Some classes and attributes are used by the frontend framework and will execute JS code, so make sure they are removed
|
||||
`<div class="link-action" data-attr-class="foo" data-url="xxx">txt</div>`, `<div data-attr-class="foo">txt</div>`,
|
||||
`<div class="form-fetch-action" data-markdown-generated-content="bar" data-global-init="a" data-global-click="b">txt</div>`, `<div data-markdown-generated-content="bar">txt</div>`,
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
// createRepoDescriptionPolicy returns a minimal more strict policy that is used for
|
||||
// repository descriptions.
|
||||
func (st *Sanitizer) createRepoDescriptionPolicy() *bluemonday.Policy {
|
||||
policy := bluemonday.NewPolicy()
|
||||
policy.AllowStandardURLs()
|
||||
|
||||
// Allow italics and bold.
|
||||
policy.AllowElements("i", "b", "em", "strong")
|
||||
|
||||
// Allow code.
|
||||
policy.AllowElements("code")
|
||||
|
||||
// Allow links
|
||||
policy.AllowAttrs("href", "target", "rel").OnElements("a")
|
||||
|
||||
// Allow classes for emojis
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("img", "span")
|
||||
policy.AllowAttrs("aria-label").OnElements("span")
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
// SanitizeDescription sanitizes the HTML generated for a repository description.
|
||||
func SanitizeDescription(s string) string {
|
||||
return GetDefaultSanitizer().descriptionPolicy.Sanitize(s)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDescriptionSanitizer(t *testing.T) {
|
||||
testCases := []string{
|
||||
`<h1>Title</h1>`, `Title`,
|
||||
`<img src='img.png' alt='image'>`, ``,
|
||||
`<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`, `<span class="emoji" aria-label="thumbs up">THUMBS UP</span>`,
|
||||
`<span style="color: red">Hello World</span>`, `<span>Hello World</span>`,
|
||||
`<br>`, ``,
|
||||
`<a href="https://example.com" target="_blank">https://example.com</a>`, `<a href="https://example.com" target="_blank" rel="nofollow noopener">https://example.com</a>`,
|
||||
`<a href="data:1234">data</a>`, `data`,
|
||||
`<mark>Important!</mark>`, `Important!`,
|
||||
`<details>Click me! <summary>Nothing to see here.</summary></details>`, `Click me! Nothing to see here.`,
|
||||
`<input type="hidden">`, ``,
|
||||
`<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`, `<b>I</b> have a <i>strong</i> <strong>opinion</strong> about <em>this</em>.`,
|
||||
`Provides alternative <code>wg(8)</code> tool`, `Provides alternative <code>wg(8)</code> tool`,
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
assert.Equal(t, testCases[i+1], SanitizeDescription(testCases[i]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user