1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-03 09:07:19 +00:00

Add sub issue list support (#32940)

Just like GitHub, show issue icon/title when the issue number is in a list
This commit is contained in:
wxiaoguang
2024-12-24 09:54:19 +08:00
committed by GitHub
parent 02c64e48b7
commit 781c6df40f
19 changed files with 332 additions and 116 deletions

View File

@ -8,6 +8,7 @@ import (
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
@ -194,3 +195,21 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
node = node.NextSibling.NextSibling
}
}
func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
found, ref := references.FindRenderizableCommitCrossReference(node.Data)
if !found {
return
}
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, "commit", ref.CommitSha), LinkTypeApp)
link := createLink(ctx, linkHref, reftext, "commit")
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
}
}

View File

@ -4,9 +4,9 @@
package markup
import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
@ -16,8 +16,16 @@ import (
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
type RenderIssueIconTitleOptions struct {
OwnerName string
RepoName string
LinkHref string
IssueIndex int64
}
func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.RenderOptions.Metas == nil {
return
@ -66,6 +74,27 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
}
}
func createIssueLinkContentWithSummary(ctx *RenderContext, linkHref string, ref *references.RenderizableReference) *html.Node {
if DefaultRenderHelperFuncs.RenderRepoIssueIconTitle == nil {
return nil
}
issueIndex, _ := strconv.ParseInt(ref.Issue, 10, 64)
h, err := DefaultRenderHelperFuncs.RenderRepoIssueIconTitle(ctx, RenderIssueIconTitleOptions{
OwnerName: ref.Owner,
RepoName: ref.Name,
LinkHref: linkHref,
IssueIndex: issueIndex,
})
if err != nil {
log.Error("RenderRepoIssueIconTitle failed: %v", err)
return nil
}
if h == "" {
return nil
}
return &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(h))}
}
func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.RenderOptions.Metas == nil {
return
@ -76,32 +105,28 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
// old logic: crossLinkOnly := ctx.RenderOptions.Metas["mode"] == "document" && !ctx.IsWiki
crossLinkOnly := ctx.RenderOptions.Metas["markupAllowShortIssuePattern"] != "true"
var (
found bool
ref *references.RenderizableReference
)
var ref *references.RenderizableReference
next := node.NextSibling
for node != nil && node != next {
_, hasExtTrackFormat := ctx.RenderOptions.Metas["format"]
// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
isNumericStyle := ctx.RenderOptions.Metas["style"] == "" || ctx.RenderOptions.Metas["style"] == IssueNameStyleNumeric
foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
switch ctx.RenderOptions.Metas["style"] {
case "", IssueNameStyleNumeric:
found, ref = foundNumeric, refNumeric
ref = refNumeric
case IssueNameStyleAlphanumeric:
found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
case IssueNameStyleRegexp:
pattern, err := regexplru.GetCompiled(ctx.RenderOptions.Metas["regexp"])
if err != nil {
return
}
found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
}
// Repos with external issue trackers might still need to reference local PRs
@ -109,17 +134,17 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
// Allow a free-pass when non-numeric pattern wasn't found.
if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) {
found = foundNumeric
if ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start {
ref = refNumeric
}
}
if !found {
if ref == nil {
return
}
var link *html.Node
reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
refText := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
if hasExtTrackFormat && !ref.IsPull {
ctx.RenderOptions.Metas["index"] = ref.Issue
@ -129,18 +154,23 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
}
link = createLink(ctx, res, reftext, "ref-issue ref-external-issue")
link = createLink(ctx, res, refText, "ref-issue ref-external-issue")
} else {
// Path determines the type of link that will be rendered. It's unknown at this point whether
// the linked item is actually a PR or an issue. Luckily it's of no real consequence because
// Gitea will redirect on click as appropriate.
issueOwner := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["user"], ref.Owner)
issueRepo := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["repo"], ref.Name)
issuePath := util.Iif(ref.IsPull, "pulls", "issues")
if ref.Owner == "" {
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"], issuePath, ref.Issue), LinkTypeApp)
link = createLink(ctx, linkHref, reftext, "ref-issue")
} else {
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, issuePath, ref.Issue), LinkTypeApp)
link = createLink(ctx, linkHref, reftext, "ref-issue")
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(issueOwner, issueRepo, issuePath, ref.Issue), LinkTypeApp)
// at the moment, only render the issue index in a full line (or simple line) as icon+title
// otherwise it would be too noisy for "take #1 as an example" in a sentence
if node.Parent.DataAtom == atom.Li && ref.RefLocation.Start < 20 && ref.RefLocation.End == len(node.Data) {
link = createIssueLinkContentWithSummary(ctx, linkHref, ref)
}
if link == nil {
link = createLink(ctx, linkHref, refText, "ref-issue")
}
}
@ -168,21 +198,3 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
node = node.NextSibling.NextSibling.NextSibling.NextSibling
}
}
func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
found, ref := references.FindRenderizableCommitCrossReference(node.Data)
if !found {
return
}
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, "commit", ref.CommitSha), LinkTypeApp)
link := createLink(ctx, linkHref, reftext, "commit")
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
}
}

View File

@ -0,0 +1,72 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup_test
import (
"context"
"html/template"
"strings"
"testing"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
testModule "code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRender_IssueList(t *testing.T) {
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
markup.Init(&markup.RenderHelperFuncs{
RenderRepoIssueIconTitle: func(ctx context.Context, opts markup.RenderIssueIconTitleOptions) (template.HTML, error) {
return htmlutil.HTMLFormat("<div>issue #%d</div>", opts.IssueIndex), nil
},
})
test := func(input, expected string) {
rctx := markup.NewTestRenderContext(markup.TestAppURL, map[string]string{
"user": "test-user", "repo": "test-repo",
"markupAllowShortIssuePattern": "true",
})
out, err := markdown.RenderString(rctx, input)
require.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out)))
}
t.Run("NormalIssueRef", func(t *testing.T) {
test(
"#12345",
`<p><a href="http://localhost:3000/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a></p>`,
)
})
t.Run("ListIssueRef", func(t *testing.T) {
test(
"* #12345",
`<ul>
<li><div>issue #12345</div></li>
</ul>`,
)
})
t.Run("ListIssueRefNormal", func(t *testing.T) {
test(
"* foo #12345 bar",
`<ul>
<li>foo <a href="http://localhost:3000/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a> bar</li>
</ul>`,
)
})
t.Run("ListTodoIssueRef", func(t *testing.T) {
test(
"* [ ] #12345",
`<ul>
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="2"/><div>issue #12345</div></li>
</ul>`,
)
})
}

View File

@ -38,6 +38,7 @@ type RenderHelper interface {
type RenderHelperFuncs struct {
IsUsernameMentionable func(ctx context.Context, username string) bool
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
RenderRepoIssueIconTitle func(ctx context.Context, options RenderIssueIconTitleOptions) (template.HTML, error)
}
var DefaultRenderHelperFuncs *RenderHelperFuncs

View File

@ -330,22 +330,22 @@ func FindAllIssueReferences(content string) []IssueReference {
}
// FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string.
func FindRenderizableReferenceNumeric(content string, prOnly, crossLinkOnly bool) (bool, *RenderizableReference) {
func FindRenderizableReferenceNumeric(content string, prOnly, crossLinkOnly bool) *RenderizableReference {
var match []int
if !crossLinkOnly {
match = issueNumericPattern.FindStringSubmatchIndex(content)
}
if match == nil {
if match = crossReferenceIssueNumericPattern.FindStringSubmatchIndex(content); match == nil {
return false, nil
return nil
}
}
r := getCrossReference(util.UnsafeStringToBytes(content), match[2], match[3], false, prOnly)
if r == nil {
return false, nil
return nil
}
return true, &RenderizableReference{
return &RenderizableReference{
Issue: r.issue,
Owner: r.owner,
Name: r.name,
@ -372,15 +372,14 @@ func FindRenderizableCommitCrossReference(content string) (bool, *RenderizableRe
}
// FindRenderizableReferenceRegexp returns the first regexp unvalidated references found in a string.
func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bool, *RenderizableReference) {
func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) *RenderizableReference {
match := pattern.FindStringSubmatchIndex(content)
if len(match) < 4 {
return false, nil
return nil
}
action, location := findActionKeywords([]byte(content), match[2])
return true, &RenderizableReference{
return &RenderizableReference{
Issue: content[match[2]:match[3]],
RefLocation: &RefSpan{Start: match[0], End: match[1]},
Action: action,
@ -390,15 +389,14 @@ func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bo
}
// FindRenderizableReferenceAlphanumeric returns the first alphanumeric unvalidated references found in a string.
func FindRenderizableReferenceAlphanumeric(content string) (bool, *RenderizableReference) {
func FindRenderizableReferenceAlphanumeric(content string) *RenderizableReference {
match := issueAlphanumericPattern.FindStringSubmatchIndex(content)
if match == nil {
return false, nil
return nil
}
action, location := findActionKeywords([]byte(content), match[2])
return true, &RenderizableReference{
return &RenderizableReference{
Issue: content[match[2]:match[3]],
RefLocation: &RefSpan{Start: match[2], End: match[3]},
Action: action,

View File

@ -249,11 +249,10 @@ func TestFindAllIssueReferences(t *testing.T) {
}
for _, fixture := range alnumFixtures {
found, ref := FindRenderizableReferenceAlphanumeric(fixture.input)
ref := FindRenderizableReferenceAlphanumeric(fixture.input)
if fixture.issue == "" {
assert.False(t, found, "Failed to parse: {%s}", fixture.input)
assert.Nil(t, ref, "Failed to parse: {%s}", fixture.input)
} else {
assert.True(t, found, "Failed to parse: {%s}", fixture.input)
assert.Equal(t, fixture.issue, ref.Issue, "Failed to parse: {%s}", fixture.input)
assert.Equal(t, fixture.refLocation, ref.RefLocation, "Failed to parse: {%s}", fixture.input)
assert.Equal(t, fixture.action, ref.Action, "Failed to parse: {%s}", fixture.input)

View File

@ -10,7 +10,7 @@ import (
"sync"
)
type normalizeVarsStruct struct {
type globalVarsStruct struct {
reXMLDoc,
reComment,
reAttrXMLNs,
@ -18,26 +18,23 @@ type normalizeVarsStruct struct {
reAttrClassPrefix *regexp.Regexp
}
var (
normalizeVars *normalizeVarsStruct
normalizeVarsOnce sync.Once
)
var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{
reXMLDoc: regexp.MustCompile(`(?s)<\?xml.*?>`),
reComment: regexp.MustCompile(`(?s)<!--.*?-->`),
reAttrXMLNs: regexp.MustCompile(`(?s)\s+xmlns\s*=\s*"[^"]*"`),
reAttrSize: regexp.MustCompile(`(?s)\s+(width|height)\s*=\s*"[^"]+"`),
reAttrClassPrefix: regexp.MustCompile(`(?s)\s+class\s*=\s*"`),
}
})
// Normalize normalizes the SVG content: set default width/height, remove unnecessary tags/attributes
// It's designed to work with valid SVG content. For invalid SVG content, the returned content is not guaranteed.
func Normalize(data []byte, size int) []byte {
normalizeVarsOnce.Do(func() {
normalizeVars = &normalizeVarsStruct{
reXMLDoc: regexp.MustCompile(`(?s)<\?xml.*?>`),
reComment: regexp.MustCompile(`(?s)<!--.*?-->`),
reAttrXMLNs: regexp.MustCompile(`(?s)\s+xmlns\s*=\s*"[^"]*"`),
reAttrSize: regexp.MustCompile(`(?s)\s+(width|height)\s*=\s*"[^"]+"`),
reAttrClassPrefix: regexp.MustCompile(`(?s)\s+class\s*=\s*"`),
}
})
data = normalizeVars.reXMLDoc.ReplaceAll(data, nil)
data = normalizeVars.reComment.ReplaceAll(data, nil)
vars := globalVars()
data = vars.reXMLDoc.ReplaceAll(data, nil)
data = vars.reComment.ReplaceAll(data, nil)
data = bytes.TrimSpace(data)
svgTag, svgRemaining, ok := bytes.Cut(data, []byte(">"))
@ -45,9 +42,9 @@ func Normalize(data []byte, size int) []byte {
return data
}
normalized := bytes.Clone(svgTag)
normalized = normalizeVars.reAttrXMLNs.ReplaceAll(normalized, nil)
normalized = normalizeVars.reAttrSize.ReplaceAll(normalized, nil)
normalized = normalizeVars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
normalized = vars.reAttrXMLNs.ReplaceAll(normalized, nil)
normalized = vars.reAttrSize.ReplaceAll(normalized, nil)
normalized = vars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
normalized = bytes.TrimSpace(normalized)
normalized = fmt.Appendf(normalized, ` width="%d" height="%d"`, size, size)
if !bytes.Contains(normalized, []byte(` class="`)) {