mirror of
https://github.com/go-gitea/gitea
synced 2026-02-03 11:10:40 +00:00
fix: generate IDs for HTML headings without id attribute (#36233)
This PR fixes #27383 where HTML headings like `<h1>Title</h1>` in markdown files would have empty permalink anchors --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
committed by
GitHub
parent
1d01286f4c
commit
f9d3983de2
@@ -70,6 +70,6 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
|
|||||||
"repo": helper.opts.DeprecatedRepoName,
|
"repo": helper.opts.DeprecatedRepoName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
rctx = rctx.WithHelper(helper)
|
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
|
||||||
return rctx
|
return rctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository,
|
|||||||
"markupAllowShortIssuePattern": "true",
|
"markupAllowShortIssuePattern": "true",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
rctx = rctx.WithHelper(helper)
|
rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true)
|
||||||
helper.ctx = rctx
|
helper.ctx = rctx
|
||||||
return rctx
|
return rctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
|
|||||||
return node.NextSibling
|
return node.NextSibling
|
||||||
}
|
}
|
||||||
|
|
||||||
processNodeAttrID(node)
|
processNodeAttrID(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) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package markup
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/markup/common"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,16 +25,57 @@ func isAnchorHrefFootnote(s string) bool {
|
|||||||
return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-")
|
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,
|
// 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
|
||||||
for idx, attr := range node.Attr {
|
for idx, attr := range node.Attr {
|
||||||
if attr.Key == "id" {
|
if attr.Key == "id" {
|
||||||
|
hasID = true
|
||||||
if !isAnchorIDUserContent(attr.Val) {
|
if !isAnchorIDUserContent(attr.Val) {
|
||||||
node.Attr[idx].Val = "user-content-" + 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) {
|
func processFootnoteNode(ctx *RenderContext, node *html.Node) {
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
|
||||||
InStandalonePage bool
|
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
|
// RenderContext represents a render context
|
||||||
@@ -112,6 +116,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext {
|
||||||
|
ctx.RenderOptions.EnableHeadingIDGeneration = v
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext {
|
func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext {
|
||||||
ctx.RenderOptions.UseAbsoluteLink = v
|
ctx.RenderOptions.UseAbsoluteLink = v
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
Reference in New Issue
Block a user