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
2 changes: 1 addition & 1 deletion models/renderhelper/repo_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
"repo": helper.opts.DeprecatedRepoName,
})
}
rctx = rctx.WithHelper(helper)
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
return rctx
}
2 changes: 1 addition & 1 deletion models/renderhelper/repo_wiki.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository,
"markupAllowShortIssuePattern": "true",
})
}
rctx = rctx.WithHelper(helper)
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
helper.ctx = rctx
return rctx
}
2 changes: 1 addition & 1 deletion modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
return node.NextSibling
}

processNodeAttrID(node)
processNodeAttrID(ctx, node)
processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly

if isEmojiNode(node) {
Expand Down
45 changes: 44 additions & 1 deletion modules/markup/html_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package markup
import (
"strings"

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

"golang.org/x/net/html"
)

Expand All @@ -23,16 +25,57 @@ func isAnchorHrefFootnote(s string) bool {
return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-")
}

func processNodeAttrID(node *html.Node) {
// 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) string {
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)
return text.String()
}

func processNodeAttrID(ctx *RenderContext, node *html.Node) {
// 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
for idx, attr := range node.Attr {
if attr.Key == "id" {
hasID = true
if !isAnchorIDUserContent(attr.Val) {
node.Attr[idx].Val = "user-content-" + attr.Val
}
}
}

// 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 != "" {
// 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})
}
}
}
}

func processFootnoteNode(ctx *RenderContext, node *html.Node) {
Expand Down
104 changes: 104 additions & 0 deletions modules/markup/html_node_test.go
Original file line number Diff line number Diff line change
@@ -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()))
})
}
}
9 changes: 9 additions & 0 deletions modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ type RenderOptions struct {

// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
InStandalonePage bool

// 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
}

// RenderContext represents a render context
Expand Down Expand Up @@ -112,6 +116,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext {
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
Expand Down