Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions modules/htmlutil/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package htmlutil
import (
"fmt"
"html/template"
"io"
"slices"
"strings"
)
Expand All @@ -31,7 +32,7 @@ func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int
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 {
panic("HTMLFormat requires one or more arguments")
}
Expand All @@ -50,5 +51,35 @@ func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML {
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
}
8 changes: 4 additions & 4 deletions modules/markup/common/footnote.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,9 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
if entering {
n := node.(*FootnoteLink)
is := strconv.Itoa(n.Index)
_, _ = w.WriteString(`<sup id="fnref:`)
_, _ = w.WriteString(`<sup id="fnref:user-content-`)
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`"><a href="#fn:`)
_, _ = 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)
Expand All @@ -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) {
if entering {
n := node.(*FootnoteBackLink)
_, _ = w.WriteString(` <a href="#fnref:`)
_, _ = w.WriteString(` <a href="#fnref:user-content-`)
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
_, _ = w.WriteString("&#x21a9;&#xfe0e;")
Expand All @@ -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) {
n := node.(*Footnote)
if entering {
_, _ = w.WriteString(`<li id="fn:`)
_, _ = w.WriteString(`<li id="fn:user-content-`)
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`" role="doc-endnote"`)
if node.Attributes() != nil {
Expand Down
50 changes: 49 additions & 1 deletion modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
"strings"
"sync"

"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/translation"

"golang.org/x/net/html"
"golang.org/x/net/html/atom"
Expand Down Expand Up @@ -234,6 +236,49 @@ func postProcessString(ctx *RenderContext, procs []processor, content string) (s
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()
Expand Down Expand Up @@ -284,6 +329,9 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output
}

// 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)
Expand Down Expand Up @@ -314,7 +362,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
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

if isEmojiNode(node) {
Expand Down
51 changes: 37 additions & 14 deletions modules/markup/html_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
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-")
return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") || isAnchorIDFootnote(s)
}

func isAnchorIDFootnote(s string) bool {
Expand All @@ -34,7 +34,10 @@ func isHeadingTag(node *html.Node) bool {
}

// 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 extractText func(*html.Node)
extractText = func(n *html.Node) {
Expand All @@ -46,36 +49,56 @@ func getNodeText(node *html.Node) string {
}
}
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,
// and convert the link href to a relative link to the host root
hasID := false
attrIDVal := ""
for idx, attr := range node.Attr {
if attr.Key == "id" {
hasID = true
if !isAnchorIDUserContent(attr.Val) {
node.Attr[idx].Val = "user-content-" + attr.Val
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.
if !hasID && isHeadingTag(node) && ctx.RenderOptions.EnableHeadingIDGeneration {
text := getNodeText(node)
if text != "" {
var nodeTextCached *string
if attrIDVal == "" {
nodeText := getNodeText(node, &nodeTextCached)
if nodeText != "" {
// Use the same CleanValue function used by Markdown heading ID generation
cleanedID := string(common.CleanValue([]byte(text)))
if cleanedID != "" {
node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: "user-content-" + cleanedID})
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) {
Expand Down
60 changes: 60 additions & 0 deletions modules/markup/html_toc_test.go
Original file line number Diff line number Diff line change
@@ -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 &lt;a&gt;</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>&lt;a&gt;</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)
}
24 changes: 10 additions & 14 deletions modules/markup/markdown/goldmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,10 @@ func (g *ASTTransformer) applyElementDir(n ast.Node) {
// Transform transforms the given AST tree.
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
firstChild := node.FirstChild()
tocMode := ""
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
rc := pc.Get(renderConfigKey).(*RenderConfig)

tocList := make([]Header, 0, 20)
tocMode := ""
if rc.yamlNode != nil {
metaNode := rc.toMetaNode(g)
if metaNode != nil {
Expand All @@ -60,8 +59,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
}

switch v := n.(type) {
case *ast.Heading:
g.transformHeading(ctx, v, reader, &tocList)
case *ast.Paragraph:
g.applyElementDir(v)
case *ast.List:
Expand All @@ -79,19 +76,18 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
return ast.WalkContinue, nil
})

showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main"
showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar
if len(tocList) > 0 && (showTocInMain || showTocInSidebar) {
if showTocInMain {
tocNode := createTOCNode(tocList, rc.Lang, nil)
node.InsertBefore(node, firstChild, tocNode)
} else {
tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"})
ctx.SidebarTocNode = tocNode
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 len(rc.Lang) > 0 {
if rc.Lang != "" {
node.SetAttributeString("lang", []byte(rc.Lang))
}
}
Expand Down
Loading