Skip to content

Fix footnote jump behavior on the issue page. (#34621) #34669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 9, 2025
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
5 changes: 4 additions & 1 deletion models/issues/comment_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package issues

import (
"context"
"strconv"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/renderhelper"
Expand Down Expand Up @@ -114,7 +115,9 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
}

var err error
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo)
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
if comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content); err != nil {
return nil, err
}
Expand Down
7 changes: 5 additions & 2 deletions models/renderhelper/repo_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type RepoCommentOptions struct {
DeprecatedRepoName string // it is only a patch for the non-standard "markup" api
DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api
CurrentRefPath string // eg: "branch/main" or "commit/11223344"
FootnoteContextID string // the extra context ID for footnotes, used to avoid conflicts with other footnotes in the same page
}

func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repository, opts ...RepoCommentOptions) *markup.RenderContext {
Expand All @@ -53,10 +54,11 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
}
rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx
var metas map[string]string
if repo != nil {
helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
rctx = rctx.WithMetas(repo.ComposeCommentMetas(ctx))
metas = repo.ComposeCommentMetas(ctx)
} else {
// this is almost dead code, only to pass the incorrect tests
helper.repoLink = fmt.Sprintf("%s/%s", helper.opts.DeprecatedOwnerName, helper.opts.DeprecatedRepoName)
Expand All @@ -68,6 +70,7 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
"markupAllowShortIssuePattern": "true",
})
}
rctx = rctx.WithHelper(helper)
metas["footnoteContextId"] = helper.opts.FootnoteContextID
rctx = rctx.WithMetas(metas).WithHelper(helper)
return rctx
}
4 changes: 2 additions & 2 deletions modules/markup/common/footnote.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,9 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`"><a href="#fn:`)
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`)
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`) // FIXME: here and below, need to keep the classes
_, _ = w.WriteString(is)
_, _ = w.WriteString(`</a></sup>`)
_, _ = w.WriteString(` </a></sup>`) // the style doesn't work at the moment, so add a space to separate the names
}
return ast.WalkContinue, nil
}
Expand Down
1 change: 1 addition & 0 deletions modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
}

processNodeAttrID(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"
Expand Down
19 changes: 19 additions & 0 deletions modules/markup/html_issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestRender_IssueList(t *testing.T) {
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)
Expand Down Expand Up @@ -69,4 +70,22 @@ func TestRender_IssueList(t *testing.T) {
</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>`,
)
})
}
20 changes: 20 additions & 0 deletions modules/markup/html_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ func isAnchorIDUserContent(s string) bool {
return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-")
}

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-")
}

func processNodeAttrID(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
Expand All @@ -27,6 +35,18 @@ func processNodeAttrID(node *html.Node) {
}
}

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" {
Expand Down
2 changes: 1 addition & 1 deletion modules/markup/markdown/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno
<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>
<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>
Expand Down
6 changes: 5 additions & 1 deletion routers/common/markup.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "comment":
rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName})
rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{
DeprecatedOwnerName: repoOwnerName,
DeprecatedRepoName: repoName,
FootnoteContextID: "preview",
})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "wiki":
rctx = renderhelper.NewRenderContextRepoUncyclo(ctx, repoModel, renderhelper.RepoUncycloOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName})
Expand Down
4 changes: 3 additions & 1 deletion routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ func UpdateIssueContent(ctx *context.Context) {
}
}

rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
FootnoteContextID: "0",
})
content, err := markdown.RenderString(rctx, issue.Content)
if err != nil {
ctx.ServerError("RenderString", err)
Expand Down
5 changes: 4 additions & 1 deletion routers/web/repo/issue_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"html/template"
"net/http"
"strconv"

issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/renderhelper"
Expand Down Expand Up @@ -278,7 +279,9 @@ func UpdateCommentContent(ctx *context.Context) {

var renderedContent template.HTML
if comment.Content != "" {
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
renderedContent, err = markdown.RenderString(rctx, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
Expand Down
13 changes: 10 additions & 3 deletions routers/web/repo/issue_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"sort"
"strconv"

activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -624,7 +625,9 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
comment.Issue = issue

if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview {
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo)
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
Expand Down Expand Up @@ -700,7 +703,9 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
}
}
} else if comment.Type.HasContentSupport() {
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo)
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
Expand Down Expand Up @@ -981,7 +986,9 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss

func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
var err error
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
FootnoteContextID: "0", // Set footnote context ID to 0 for the issue content
})
issue.RenderedContent, err = markdown.RenderString(rctx, issue.Content)
if err != nil {
ctx.ServerError("RenderString", err)
Expand Down
5 changes: 4 additions & 1 deletion routers/web/repo/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"

"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -113,7 +114,9 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
cacheUsers[r.PublisherID] = r.Publisher
}

rctx := renderhelper.NewRenderContextRepoComment(ctx, r.Repo)
rctx := renderhelper.NewRenderContextRepoComment(ctx, r.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(r.ID, 10),
})
r.RenderedNote, err = markdown.RenderString(rctx, r.Note)
if err != nil {
return nil, err
Expand Down
25 changes: 14 additions & 11 deletions web_src/js/markup/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@ const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');

// scroll to anchor while respecting the `user-content` prefix that exists on the target
function scrollToAnchor(encodedId: string): void {
if (!encodedId) return;
const id = decodeURIComponent(encodedId);
const prefixedId = addPrefix(id);
let el = document.querySelector(`#${prefixedId}`);
function scrollToAnchor(encodedId?: string): void {
// FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here
let elemId: string;
try {
elemId = decodeURIComponent(encodedId ?? '');
} catch {} // ignore the errors, since the "encodedId" is from user's input
if (!elemId) return;

const prefixedId = addPrefix(elemId);
// eslint-disable-next-line unicorn/prefer-query-selector
let el = document.getElementById(prefixedId);

// check for matching user-generated `a[name]`
if (!el) {
el = document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`);
}
el = el ?? document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`);

// compat for links with old 'user-content-' prefixed hashes
if (!el && hasPrefix(id)) {
return document.querySelector(`#${id}`)?.scrollIntoView();
}
// eslint-disable-next-line unicorn/prefer-query-selector
el = (!el && hasPrefix(elemId)) ? document.getElementById(elemId) : el;

el?.scrollIntoView();
}
Expand Down