mirror of
https://github.com/go-gitea/gitea
synced 2026-02-03 11:10:40 +00:00
Fix markup heading parsing, fix emphasis parsing (#36284)
Fixes #36106, fix #17958 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ package htmlutil
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -31,7 +32,7 @@ func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int
|
|||||||
return size, class
|
return size, class
|
||||||
}
|
}
|
||||||
|
|
||||||
func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML {
|
func htmlFormatArgs(s template.HTML, rawArgs []any) []any {
|
||||||
if !strings.Contains(string(s), "%") || len(rawArgs) == 0 {
|
if !strings.Contains(string(s), "%") || len(rawArgs) == 0 {
|
||||||
panic("HTMLFormat requires one or more arguments")
|
panic("HTMLFormat requires one or more arguments")
|
||||||
}
|
}
|
||||||
@@ -50,5 +51,35 @@ func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML {
|
|||||||
args[i] = template.HTMLEscapeString(fmt.Sprint(v))
|
args[i] = template.HTMLEscapeString(fmt.Sprint(v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return template.HTML(fmt.Sprintf(string(s), args...))
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML {
|
||||||
|
return template.HTML(fmt.Sprintf(string(s), htmlFormatArgs(s, rawArgs)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTMLPrintf(w io.Writer, s template.HTML, rawArgs ...any) (int, error) {
|
||||||
|
return fmt.Fprintf(w, string(s), htmlFormatArgs(s, rawArgs)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTMLPrint(w io.Writer, s template.HTML) (int, error) {
|
||||||
|
return io.WriteString(w, string(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTMLPrintTag(w io.Writer, tag template.HTML, attrs map[string]string) (written int, err error) {
|
||||||
|
n, err := io.WriteString(w, "<"+string(tag))
|
||||||
|
written += n
|
||||||
|
if err != nil {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
for k, v := range attrs {
|
||||||
|
n, err = fmt.Fprintf(w, ` %s="%s"`, template.HTMLEscapeString(k), template.HTMLEscapeString(v))
|
||||||
|
written += n
|
||||||
|
if err != nil {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err = io.WriteString(w, ">")
|
||||||
|
written += n
|
||||||
|
return written, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,9 +405,9 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
|
|||||||
if entering {
|
if entering {
|
||||||
n := node.(*FootnoteLink)
|
n := node.(*FootnoteLink)
|
||||||
is := strconv.Itoa(n.Index)
|
is := strconv.Itoa(n.Index)
|
||||||
_, _ = w.WriteString(`<sup id="fnref:`)
|
_, _ = w.WriteString(`<sup id="fnref:user-content-`)
|
||||||
_, _ = w.Write(n.Name)
|
_, _ = w.Write(n.Name)
|
||||||
_, _ = w.WriteString(`"><a href="#fn:`)
|
_, _ = w.WriteString(`"><a href="#fn:user-content-`)
|
||||||
_, _ = w.Write(n.Name)
|
_, _ = w.Write(n.Name)
|
||||||
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`) // FIXME: here and below, need to keep the classes
|
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`) // FIXME: here and below, need to keep the classes
|
||||||
_, _ = w.WriteString(is)
|
_, _ = w.WriteString(is)
|
||||||
@@ -419,7 +419,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
|
|||||||
func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
if entering {
|
if entering {
|
||||||
n := node.(*FootnoteBackLink)
|
n := node.(*FootnoteBackLink)
|
||||||
_, _ = w.WriteString(` <a href="#fnref:`)
|
_, _ = w.WriteString(` <a href="#fnref:user-content-`)
|
||||||
_, _ = w.Write(n.Name)
|
_, _ = w.Write(n.Name)
|
||||||
_, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
|
_, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
|
||||||
_, _ = w.WriteString("↩︎")
|
_, _ = w.WriteString("↩︎")
|
||||||
@@ -431,7 +431,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source [
|
|||||||
func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
n := node.(*Footnote)
|
n := node.(*Footnote)
|
||||||
if entering {
|
if entering {
|
||||||
_, _ = w.WriteString(`<li id="fn:`)
|
_, _ = w.WriteString(`<li id="fn:user-content-`)
|
||||||
_, _ = w.Write(n.Name)
|
_, _ = w.Write(n.Name)
|
||||||
_, _ = w.WriteString(`" role="doc-endnote"`)
|
_, _ = w.WriteString(`" role="doc-endnote"`)
|
||||||
if node.Attributes() != nil {
|
if node.Attributes() != nil {
|
||||||
|
|||||||
+49
-1
@@ -12,7 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/htmlutil"
|
||||||
"code.gitea.io/gitea/modules/markup/common"
|
"code.gitea.io/gitea/modules/markup/common"
|
||||||
|
"code.gitea.io/gitea/modules/translation"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
@@ -234,6 +236,49 @@ func postProcessString(ctx *RenderContext, procs []processor, content string) (s
|
|||||||
return buf.String(), nil
|
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 {
|
func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error {
|
||||||
if !ctx.usedByRender && ctx.RenderHelper != nil {
|
if !ctx.usedByRender && ctx.RenderHelper != nil {
|
||||||
defer ctx.RenderHelper.CleanUp()
|
defer ctx.RenderHelper.CleanUp()
|
||||||
@@ -284,6 +329,9 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render everything to buf.
|
// Render everything to buf.
|
||||||
|
if ctx.TocShowInSection == TocShowInMain && len(ctx.TocHeadingItems) > 0 {
|
||||||
|
RenderTocHeadingItems(ctx, nil, output)
|
||||||
|
}
|
||||||
for _, node := range newNodes {
|
for _, node := range newNodes {
|
||||||
if err := html.Render(output, node); err != nil {
|
if err := html.Render(output, node); err != nil {
|
||||||
return fmt.Errorf("markup.postProcess: html.Render: %w", err)
|
return fmt.Errorf("markup.postProcess: html.Render: %w", err)
|
||||||
@@ -314,7 +362,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
|
|||||||
return node.NextSibling
|
return node.NextSibling
|
||||||
}
|
}
|
||||||
|
|
||||||
processNodeAttrID(ctx, node)
|
processNodeHeadingAndID(ctx, node)
|
||||||
processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly
|
processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly
|
||||||
|
|
||||||
if isEmojiNode(node) {
|
if isEmojiNode(node) {
|
||||||
|
|||||||
+37
-14
@@ -14,7 +14,7 @@ import (
|
|||||||
func isAnchorIDUserContent(s string) bool {
|
func isAnchorIDUserContent(s string) bool {
|
||||||
// blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote
|
// blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote
|
||||||
// old logic: blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`)
|
// old logic: blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`)
|
||||||
return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-")
|
return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") || isAnchorIDFootnote(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAnchorIDFootnote(s string) bool {
|
func isAnchorIDFootnote(s string) bool {
|
||||||
@@ -34,7 +34,10 @@ func isHeadingTag(node *html.Node) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getNodeText extracts the text content from a node and its children
|
// getNodeText extracts the text content from a node and its children
|
||||||
func getNodeText(node *html.Node) string {
|
func getNodeText(node *html.Node, cached **string) string {
|
||||||
|
if *cached != nil {
|
||||||
|
return **cached
|
||||||
|
}
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
var extractText func(*html.Node)
|
var extractText func(*html.Node)
|
||||||
extractText = func(n *html.Node) {
|
extractText = func(n *html.Node) {
|
||||||
@@ -46,36 +49,56 @@ func getNodeText(node *html.Node) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
extractText(node)
|
extractText(node)
|
||||||
return text.String()
|
textStr := text.String()
|
||||||
|
*cached = &textStr
|
||||||
|
return textStr
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNodeAttrID(ctx *RenderContext, node *html.Node) {
|
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,
|
// 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
|
// and convert the link href to a relative link to the host root
|
||||||
hasID := false
|
attrIDVal := ""
|
||||||
for idx, attr := range node.Attr {
|
for idx, attr := range node.Attr {
|
||||||
if attr.Key == "id" {
|
if attr.Key == "id" {
|
||||||
hasID = true
|
attrIDVal = attr.Val
|
||||||
if !isAnchorIDUserContent(attr.Val) {
|
if !isAnchorIDUserContent(attrIDVal) {
|
||||||
node.Attr[idx].Val = "user-content-" + attr.Val
|
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.
|
// 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
|
// This ensures HTML headings like <h1>Title</h1> get proper permalink anchors
|
||||||
// matching the behavior of Markdown headings.
|
// matching the behavior of Markdown headings.
|
||||||
// Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option.
|
// Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option.
|
||||||
if !hasID && isHeadingTag(node) && ctx.RenderOptions.EnableHeadingIDGeneration {
|
var nodeTextCached *string
|
||||||
text := getNodeText(node)
|
if attrIDVal == "" {
|
||||||
if text != "" {
|
nodeText := getNodeText(node, &nodeTextCached)
|
||||||
|
if nodeText != "" {
|
||||||
// Use the same CleanValue function used by Markdown heading ID generation
|
// Use the same CleanValue function used by Markdown heading ID generation
|
||||||
cleanedID := string(common.CleanValue([]byte(text)))
|
attrIDVal = string(common.CleanValue([]byte(nodeText)))
|
||||||
if cleanedID != "" {
|
if attrIDVal != "" {
|
||||||
node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: "user-content-" + cleanedID})
|
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) {
|
func processFootnoteNode(ctx *RenderContext, node *html.Node) {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package markup_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/markup"
|
||||||
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
|
"code.gitea.io/gitea/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)
|
||||||
|
}
|
||||||
@@ -41,11 +41,10 @@ func (g *ASTTransformer) applyElementDir(n ast.Node) {
|
|||||||
// Transform transforms the given AST tree.
|
// Transform transforms the given AST tree.
|
||||||
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
||||||
firstChild := node.FirstChild()
|
firstChild := node.FirstChild()
|
||||||
tocMode := ""
|
|
||||||
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
|
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
|
||||||
rc := pc.Get(renderConfigKey).(*RenderConfig)
|
rc := pc.Get(renderConfigKey).(*RenderConfig)
|
||||||
|
|
||||||
tocList := make([]Header, 0, 20)
|
tocMode := ""
|
||||||
if rc.yamlNode != nil {
|
if rc.yamlNode != nil {
|
||||||
metaNode := rc.toMetaNode(g)
|
metaNode := rc.toMetaNode(g)
|
||||||
if metaNode != nil {
|
if metaNode != nil {
|
||||||
@@ -60,8 +59,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch v := n.(type) {
|
switch v := n.(type) {
|
||||||
case *ast.Heading:
|
|
||||||
g.transformHeading(ctx, v, reader, &tocList)
|
|
||||||
case *ast.Paragraph:
|
case *ast.Paragraph:
|
||||||
g.applyElementDir(v)
|
g.applyElementDir(v)
|
||||||
case *ast.List:
|
case *ast.List:
|
||||||
@@ -79,19 +76,18 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
|||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if ctx.RenderOptions.EnableHeadingIDGeneration {
|
||||||
showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main"
|
showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main"
|
||||||
showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar
|
showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar
|
||||||
if len(tocList) > 0 && (showTocInMain || showTocInSidebar) {
|
switch {
|
||||||
if showTocInMain {
|
case showTocInMain:
|
||||||
tocNode := createTOCNode(tocList, rc.Lang, nil)
|
ctx.TocShowInSection = markup.TocShowInMain
|
||||||
node.InsertBefore(node, firstChild, tocNode)
|
case showTocInSidebar:
|
||||||
} else {
|
ctx.TocShowInSection = markup.TocShowInSidebar
|
||||||
tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"})
|
|
||||||
ctx.SidebarTocNode = tocNode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(rc.Lang) > 0 {
|
if rc.Lang != "" {
|
||||||
node.SetAttributeString("lang", []byte(rc.Lang))
|
node.SetAttributeString("lang", []byte(rc.Lang))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package markdown
|
package markdown
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
@@ -21,10 +22,12 @@ import (
|
|||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||||
meta "github.com/yuin/goldmark-meta"
|
meta "github.com/yuin/goldmark-meta"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/renderer"
|
"github.com/yuin/goldmark/renderer"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ func (l *limitWriter) Write(data []byte) (int, error) {
|
|||||||
|
|
||||||
// newParserContext creates a parser.Context with the render context set
|
// newParserContext creates a parser.Context with the render context set
|
||||||
func newParserContext(ctx *markup.RenderContext) parser.Context {
|
func newParserContext(ctx *markup.RenderContext) parser.Context {
|
||||||
pc := parser.NewContext(parser.WithIDs(newPrefixedIDs()))
|
pc := parser.NewContext()
|
||||||
pc.Set(renderContextKey, ctx)
|
pc.Set(renderContextKey, ctx)
|
||||||
return pc
|
return pc
|
||||||
}
|
}
|
||||||
@@ -101,12 +104,48 @@ func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.C
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// SpecializedMarkdown sets up the Gitea specific markdown extensions
|
||||||
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
|
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
|
||||||
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
|
// 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)
|
// at the moment it is fast enough (see the benchmarks)
|
||||||
r := &GlodmarkRender{ctx: ctx}
|
r := &GlodmarkRender{ctx: ctx}
|
||||||
r.goldmarkMarkdown = goldmark.New(
|
r.goldmarkMarkdown = goldmark.New(
|
||||||
|
goldmark.WithParser(goldmarkDefaultParser()),
|
||||||
goldmark.WithExtensions(
|
goldmark.WithExtensions(
|
||||||
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
|
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
|
||||||
extension.Strikethrough,
|
extension.Strikethrough,
|
||||||
@@ -131,7 +170,6 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
|
|||||||
),
|
),
|
||||||
goldmark.WithParserOptions(
|
goldmark.WithParserOptions(
|
||||||
parser.WithAttribute(),
|
parser.WithAttribute(),
|
||||||
parser.WithAutoHeadingID(),
|
|
||||||
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
|
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
|
||||||
),
|
),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ func TestRender_Images(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTotal_RenderString(t *testing.T) {
|
func TestTotal_RenderString(t *testing.T) {
|
||||||
|
setting.AppURL = AppURL
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
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)
|
// Test cases without ambiguous links (It is not right to copy a whole file here, instead it should clearly test what is being tested)
|
||||||
@@ -258,7 +259,7 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
for i := range sameCases {
|
for i := range sameCases {
|
||||||
line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), sameCases[i])
|
line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas).WithEnableHeadingIDGeneration(true), sameCases[i])
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, testAnswers[i], string(line))
|
assert.Equal(t, testAnswers[i], string(line))
|
||||||
}
|
}
|
||||||
@@ -545,5 +546,11 @@ func TestMarkdownLink(t *testing.T) {
|
|||||||
assert.Equal(t, `<p><a href="/base/foo" rel="nofollow">link1</a>
|
assert.Equal(t, `<p><a href="/base/foo" rel="nofollow">link1</a>
|
||||||
<a href="/base/foo" rel="nofollow">link2</a>
|
<a href="/base/foo" rel="nofollow">link2</a>
|
||||||
<a href="#user-content-foo" rel="nofollow">link3</a></p>
|
<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))
|
`, string(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package markdown
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/container"
|
|
||||||
"code.gitea.io/gitea/modules/markup/common"
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
)
|
|
||||||
|
|
||||||
type prefixedIDs struct {
|
|
||||||
values container.Set[string]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate generates a new element id.
|
|
||||||
func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
|
|
||||||
dft := []byte("id")
|
|
||||||
if kind == ast.KindHeading {
|
|
||||||
dft = []byte("heading")
|
|
||||||
}
|
|
||||||
return p.GenerateWithDefault(value, dft)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateWithDefault generates a new element id.
|
|
||||||
func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte {
|
|
||||||
result := common.CleanValue(value)
|
|
||||||
if len(result) == 0 {
|
|
||||||
result = dft
|
|
||||||
}
|
|
||||||
if !bytes.HasPrefix(result, []byte("user-content-")) {
|
|
||||||
result = append([]byte("user-content-"), result...)
|
|
||||||
}
|
|
||||||
if p.values.Add(util.UnsafeBytesToString(result)) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for i := 1; ; i++ {
|
|
||||||
newResult := fmt.Sprintf("%s-%d", result, i)
|
|
||||||
if p.values.Add(newResult) {
|
|
||||||
return []byte(newResult)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put puts a given element id to the used ids table.
|
|
||||||
func (p *prefixedIDs) Put(value []byte) {
|
|
||||||
p.values.Add(util.UnsafeBytesToString(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPrefixedIDs() *prefixedIDs {
|
|
||||||
return &prefixedIDs{
|
|
||||||
values: make(container.Set[string]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package markdown
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/translation"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Header holds the data about a header.
|
|
||||||
type Header struct {
|
|
||||||
Level int
|
|
||||||
Text string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTOCNode(toc []Header, lang string, detailsAttrs map[string]string) ast.Node {
|
|
||||||
details := NewDetails()
|
|
||||||
summary := NewSummary()
|
|
||||||
|
|
||||||
for k, v := range detailsAttrs {
|
|
||||||
details.SetAttributeString(k, []byte(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc"))))
|
|
||||||
details.AppendChild(details, summary)
|
|
||||||
ul := ast.NewList('-')
|
|
||||||
details.AppendChild(details, ul)
|
|
||||||
currentLevel := 6
|
|
||||||
for _, header := range toc {
|
|
||||||
if header.Level < currentLevel {
|
|
||||||
currentLevel = header.Level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, header := range toc {
|
|
||||||
for currentLevel > header.Level {
|
|
||||||
ul = ul.Parent().(*ast.List)
|
|
||||||
currentLevel--
|
|
||||||
}
|
|
||||||
for currentLevel < header.Level {
|
|
||||||
newL := ast.NewList('-')
|
|
||||||
ul.AppendChild(ul, newL)
|
|
||||||
currentLevel++
|
|
||||||
ul = newL
|
|
||||||
}
|
|
||||||
li := ast.NewListItem(currentLevel * 2)
|
|
||||||
a := ast.NewLink()
|
|
||||||
a.Destination = []byte("#" + url.QueryEscape(header.ID))
|
|
||||||
a.AppendChild(a, ast.NewString([]byte(header.Text)))
|
|
||||||
li.AppendChild(li, a)
|
|
||||||
ul.AppendChild(ul, li)
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package markdown
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/markup"
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
"github.com/yuin/goldmark/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]Header) {
|
|
||||||
for _, attr := range v.Attributes() {
|
|
||||||
if _, ok := attr.Value.([]byte); !ok {
|
|
||||||
v.SetAttribute(attr.Name, fmt.Appendf(nil, "%v", attr.Value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
txt := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated
|
|
||||||
header := Header{
|
|
||||||
Text: util.UnsafeBytesToString(txt),
|
|
||||||
Level: v.Level,
|
|
||||||
}
|
|
||||||
if id, found := v.AttributeString("id"); found {
|
|
||||||
header.ID = util.UnsafeBytesToString(id.([]byte))
|
|
||||||
}
|
|
||||||
*tocList = append(*tocList, header)
|
|
||||||
g.applyElementDir(v)
|
|
||||||
}
|
|
||||||
@@ -165,7 +165,6 @@ func StripMarkdownBytes(rawBytes []byte) ([]byte, []string) {
|
|||||||
),
|
),
|
||||||
goldmark.WithParserOptions(
|
goldmark.WithParserOptions(
|
||||||
parser.WithAttribute(),
|
parser.WithAttribute(),
|
||||||
parser.WithAutoHeadingID(),
|
|
||||||
),
|
),
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
html.WithUnsafe(),
|
html.WithUnsafe(),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +59,19 @@ type RenderOptions struct {
|
|||||||
EnableHeadingIDGeneration bool
|
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
|
// RenderContext represents a render context
|
||||||
type RenderContext struct {
|
type RenderContext struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -67,7 +79,8 @@ type RenderContext struct {
|
|||||||
// the context might be used by the "render" function, but it might also be used by "postProcess" function
|
// the context might be used by the "render" function, but it might also be used by "postProcess" function
|
||||||
usedByRender bool
|
usedByRender bool
|
||||||
|
|
||||||
SidebarTocNode ast.Node
|
TocShowInSection TocShowInSectionType
|
||||||
|
TocHeadingItems []*TocHeadingItem
|
||||||
|
|
||||||
RenderHelper RenderHelper
|
RenderHelper RenderHelper
|
||||||
RenderOptions RenderOptions
|
RenderOptions RenderOptions
|
||||||
|
|||||||
@@ -277,12 +277,10 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if rctx.SidebarTocNode != nil {
|
if rctx.TocShowInSection == markup.TocShowInSidebar && len(rctx.TocHeadingItems) > 0 {
|
||||||
sb := strings.Builder{}
|
sb := strings.Builder{}
|
||||||
if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil {
|
markup.RenderTocHeadingItems(rctx, map[string]string{"open": ""}, &sb)
|
||||||
log.Error("Failed to render wiki sidebar TOC: %v", err)
|
ctx.Data["WikiSidebarTocHTML"] = template.HTML(sb.String())
|
||||||
}
|
|
||||||
ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSideBar {
|
if !isSideBar {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {svg} from '../svg.ts';
|
import {svg} from '../svg.ts';
|
||||||
|
|
||||||
|
// FIXME: don't see why these tricks make sense. If these prefixes are not needed, they should be removed entirely by backend.
|
||||||
const addPrefix = (str: string): string => `user-content-${str}`;
|
const addPrefix = (str: string): string => `user-content-${str}`;
|
||||||
const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
|
const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
|
||||||
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
|
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
|
||||||
|
|||||||
Reference in New Issue
Block a user