feat(ui): add more emoji and code block rendering in issues

This commit is contained in:
Bram Hagens 2024-07-17 01:37:20 +02:00
parent 60bcdc8bc3
commit 4a74113dee
No known key found for this signature in database
GPG key ID: CEF9728B2127ECDC
14 changed files with 322 additions and 34 deletions

View file

@ -73,6 +73,8 @@ var (
// EmojiShortCodeRegex find emoji by alias like :smile: // EmojiShortCodeRegex find emoji by alias like :smile:
EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`) EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`")
) )
// CSS class for action keywords (e.g. "closes: #1") // CSS class for action keywords (e.g. "closes: #1")
@ -243,6 +245,7 @@ func RenderIssueTitle(
title string, title string,
) (string, error) { ) (string, error) {
return renderProcessString(ctx, []processor{ return renderProcessString(ctx, []processor{
inlineCodeBlockProcessor,
issueIndexPatternProcessor, issueIndexPatternProcessor,
commitCrossReferencePatternProcessor, commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor, hashCurrentPatternProcessor,
@ -251,6 +254,19 @@ func RenderIssueTitle(
}, title) }, title)
} }
// RenderRefIssueTitle to process title on places where an issue is referenced
func RenderRefIssueTitle(
ctx *RenderContext,
title string,
) (string, error) {
return renderProcessString(ctx, []processor{
inlineCodeBlockProcessor,
issueIndexPatternProcessor,
emojiShortCodeProcessor,
emojiProcessor,
}, title)
}
func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
var buf strings.Builder var buf strings.Builder
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
@ -438,6 +454,24 @@ func createKeyword(content string) *html.Node {
return span return span
} }
func createInlineCode(content string) *html.Node {
code := &html.Node{
Type: html.ElementNode,
Data: atom.Code.String(),
Attr: []html.Attribute{},
}
code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"})
text := &html.Node{
Type: html.TextNode,
Data: content,
}
code.AppendChild(text)
return code
}
func createEmoji(content, class, name string) *html.Node { func createEmoji(content, class, name string) *html.Node {
span := &html.Node{ span := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
@ -1070,6 +1104,21 @@ func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
} }
} }
func inlineCodeBlockProcessor(ctx *RenderContext, node *html.Node) {
start := 0
next := node.NextSibling
for node != nil && node != next && start < len(node.Data) {
m := InlineCodeBlockRegex.FindStringSubmatchIndex(node.Data[start:])
if m == nil {
return
}
code := node.Data[m[0]+1 : m[1]-1]
replaceContent(node, m[0], m[1], createInlineCode(code))
node = node.NextSibling.NextSibling
}
}
// emojiShortCodeProcessor for rendering text like :smile: into emoji // emojiShortCodeProcessor for rendering text like :smile: into emoji
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
start := 0 start := 0

View file

@ -175,6 +175,7 @@ func NewFuncMap() template.FuncMap {
"RenderCommitBody": RenderCommitBody, "RenderCommitBody": RenderCommitBody,
"RenderCodeBlock": RenderCodeBlock, "RenderCodeBlock": RenderCodeBlock,
"RenderIssueTitle": RenderIssueTitle, "RenderIssueTitle": RenderIssueTitle,
"RenderRefIssueTitle": RenderRefIssueTitle,
"RenderEmoji": RenderEmoji, "RenderEmoji": RenderEmoji,
"ReactionToEmoji": ReactionToEmoji, "ReactionToEmoji": ReactionToEmoji,

View file

@ -130,6 +130,17 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
return template.HTML(renderedText) return template.HTML(renderedText)
} }
// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
if err != nil {
log.Error("RenderRefIssueTitle: %v", err)
return ""
}
return template.HTML(renderedText)
}
// RenderLabel renders a label // RenderLabel renders a label
// locale is needed due to an import cycle with our context providing the `Tr` function // locale is needed due to an import cycle with our context providing the `Tr` function
func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {

View file

@ -36,7 +36,7 @@ mail@domain.com
@mention-user test @mention-user test
#123 #123
space space
` ` + "`code :+1: #123 code`\n"
var testMetas = map[string]string{ var testMetas = map[string]string{
"user": "user13", "user": "user13",
@ -115,8 +115,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a> <a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
<a href="/mention-user" class="mention">@mention-user</a> test <a href="/mention-user" class="mention">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space` space
` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">👍</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`"
assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas)) assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
} }
@ -153,10 +153,37 @@ mail@domain.com
@mention-user test @mention-user test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space space
<code class="inline-code-block">code :+1: #123 code</code>
` `
assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas)) assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
} }
func TestRenderRefIssueTitle(t *testing.T) {
expected := ` space @mention-user
/just/a/path.bin
https://example.com/file.bin
[local link](file.bin)
[remote link](https://example.com)
[[local link|file.bin]]
[[remote link|https://example.com]]
![local image](image.jpg)
![remote image](https://example.com/image.jpg)
[[local image|image.jpg]]
[[remote link|https://example.com/image.jpg]]
https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span>
mail@domain.com
@mention-user test
#123
space
<code class="inline-code-block">code :+1: #123 code</code>
`
assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput))
}
func TestRenderMarkdownToHtml(t *testing.T) { func TestRenderMarkdownToHtml(t *testing.T) {
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/> expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
/just/a/path.bin /just/a/path.bin
@ -177,7 +204,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a> <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
<a href="/mention-user" rel="nofollow">@mention-user</a> test <a href="/mention-user" rel="nofollow">@mention-user</a> test
#123 #123
space</p> space
<code>code :+1: #123 code</code></p>
` `
assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput)) assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput))
} }

View file

@ -14,7 +14,7 @@
<div class="issue-card-icon"> <div class="issue-card-icon">
{{template "shared/issueicon" .}} {{template "shared/issueicon" .}}
</div> </div>
<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{.Title | RenderEmoji ctx | RenderCodeBlock}}</a> <a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{RenderRefIssueTitle $.Context .Title}}</a>
{{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}} {{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}}
<a role="button" class="issue-card-unpin muted tw-flex tw-items-center" data-tooltip-content={{ctx.Locale.Tr "repo.issues.unpin_issue"}} data-issue-id="{{.ID}}" data-unpin-url="{{$.Page.Link}}/unpin/{{.Index}}"> <a role="button" class="issue-card-unpin muted tw-flex tw-items-center" data-tooltip-content={{ctx.Locale.Tr "repo.issues.unpin_issue"}} data-issue-id="{{.ID}}" data-unpin-url="{{$.Page.Link}}/unpin/{{.Index}}">
{{svg "octicon-x" 16}} {{svg "octicon-x" 16}}

View file

@ -149,7 +149,7 @@
{{if eq .RefAction 3}}</del>{{end}} {{if eq .RefAction 3}}</del>{{end}}
<div class="detail flex-text-block"> <div class="detail flex-text-block">
<span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx}}</b> {{.RefIssueIdent ctx}}</a></span> <span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx | RenderEmoji $.Context | RenderCodeBlock}}</b> {{.RefIssueIdent ctx}}</a></span>
</div> </div>
</div> </div>
{{else if eq .Type 4}} {{else if eq .Type 4}}
@ -226,7 +226,7 @@
{{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/avatarlink" dict "user" .Poster}}
<span class="text grey muted-links"> <span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{ctx.Locale.Tr "repo.issues.change_title_at" (.OldTitle|RenderEmoji $.Context) (.NewTitle|RenderEmoji $.Context) $createdStr}} {{ctx.Locale.Tr "repo.issues.change_title_at" (RenderRefIssueTitle $.Context .OldTitle) (RenderRefIssueTitle $.Context .NewTitle) $createdStr}}
</span> </span>
</div> </div>
{{else if eq .Type 11}} {{else if eq .Type 11}}
@ -339,10 +339,11 @@
{{svg "octicon-plus"}} {{svg "octicon-plus"}}
<span class="text grey muted-links"> <span class="text grey muted-links">
<a href="{{.DependentIssue.Link}}"> <a href="{{.DependentIssue.Link}}">
{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
{{if eq .DependentIssue.RepoID .Issue.RepoID}} {{if eq .DependentIssue.RepoID .Issue.RepoID}}
#{{.DependentIssue.Index}} {{.DependentIssue.Title}} #{{.DependentIssue.Index}} {{$strTitle}}
{{else}} {{else}}
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}} {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
{{end}} {{end}}
</a> </a>
</span> </span>
@ -362,10 +363,11 @@
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
<span class="text grey muted-links"> <span class="text grey muted-links">
<a href="{{.DependentIssue.Link}}"> <a href="{{.DependentIssue.Link}}">
{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
{{if eq .DependentIssue.RepoID .Issue.RepoID}} {{if eq .DependentIssue.RepoID .Issue.RepoID}}
#{{.DependentIssue.Index}} {{.DependentIssue.Title}} #{{.DependentIssue.Index}} {{$strTitle}}
{{else}} {{else}}
{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}} {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
{{end}} {{end}}
</a> </a>
</span> </span>

View file

@ -19,8 +19,8 @@
{{range .BlockingDependencies}} {{range .BlockingDependencies}}
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between"> <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> <a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}">
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}
</a> </a>
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
{{.Repository.OwnerName}}/{{.Repository.Name}} {{.Repository.OwnerName}}/{{.Repository.Name}}
@ -51,8 +51,9 @@
{{range .BlockedByDependencies}} {{range .BlockedByDependencies}}
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between"> <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> {{$title := RenderRefIssueTitle $.Context .Issue.Title}}
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} <a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}">
#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}
</a> </a>
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
{{.Repository.OwnerName}}/{{.Repository.Name}} {{.Repository.OwnerName}}/{{.Repository.Name}}
@ -73,8 +74,8 @@
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<div class="gt-ellipsis"> <div class="gt-ellipsis">
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span> <span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
<span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> <span class="title" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}">
#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}
</span> </span>
</div> </div>
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">

View file

@ -7,8 +7,7 @@
{{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}} {{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
<div class="issue-title" id="issue-title-display"> <div class="issue-title" id="issue-title-display">
<h1 class="tw-break-anywhere"> <h1 class="tw-break-anywhere">
{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}} {{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx)}} <span class="index">#{{.Issue.Index}}</span>
<span class="index">#{{.Issue.Index}}</span>
</h1> </h1>
<div class="button-row"> <div class="button-row">
{{if $canEditIssueTitle}} {{if $canEditIssueTitle}}

View file

@ -153,7 +153,7 @@
{{range .Activity.MergedPRs}} {{range .Activity.MergedPRs}}
<p class="desc"> <p class="desc">
<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span> <span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
{{TimeSinceUnix .MergedUnix ctx.Locale}} {{TimeSinceUnix .MergedUnix ctx.Locale}}
</p> </p>
{{end}} {{end}}
@ -172,7 +172,7 @@
{{range .Activity.OpenedPRs}} {{range .Activity.OpenedPRs}}
<p class="desc"> <p class="desc">
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span> <span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} {{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
</p> </p>
{{end}} {{end}}
@ -191,7 +191,7 @@
{{range .Activity.ClosedIssues}} {{range .Activity.ClosedIssues}}
<p class="desc"> <p class="desc">
<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span> <span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
{{TimeSinceUnix .ClosedUnix ctx.Locale}} {{TimeSinceUnix .ClosedUnix ctx.Locale}}
</p> </p>
{{end}} {{end}}
@ -210,7 +210,7 @@
{{range .Activity.OpenedIssues}} {{range .Activity.OpenedIssues}}
<p class="desc"> <p class="desc">
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span> <span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
{{TimeSinceUnix .CreatedUnix ctx.Locale}} {{TimeSinceUnix .CreatedUnix ctx.Locale}}
</p> </p>
{{end}} {{end}}
@ -228,9 +228,9 @@
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span> <span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
#{{.Index}} #{{.Index}}
{{if .IsPull}} {{if .IsPull}}
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
{{else}} {{else}}
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
{{end}} {{end}}
{{TimeSinceUnix .UpdatedUnix ctx.Locale}} {{TimeSinceUnix .UpdatedUnix ctx.Locale}}
</p> </p>

View file

@ -58,7 +58,7 @@
<div class="notifications-bottom-row tw-text-16 tw-py-0.5"> <div class="notifications-bottom-row tw-text-16 tw-py-0.5">
<span class="issue-title tw-break-anywhere"> <span class="issue-title tw-break-anywhere">
{{if .Issue}} {{if .Issue}}
{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}} {{RenderRefIssueTitle $.Context .Issue.Title}}
{{else}} {{else}}
{{.Repository.FullName}} {{.Repository.FullName}}
{{end}} {{end}}

View file

@ -0,0 +1,162 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
files_service "code.gitea.io/gitea/services/repository/files"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueTitles(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, _, f := tests.CreateDeclarativeRepo(t, user, "issue-titles", nil, nil, nil)
defer f()
session := loginUser(t, user.LoginName)
title := "Title :+1: `code`"
issue1 := createIssue(t, user, repo, title, "Test issue")
issue2 := createIssue(t, user, repo, title, "Ref #1")
titleHTML := []string{
"Title",
`<span class="emoji" aria-label="thumbs up">👍</span>`,
`<code class="inline-code-block">code</code>`,
}
t.Run("Main issue title", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
html := extractHTML(t, session, issue1, "div.issue-title-header > * > h1")
assertContainsAll(t, titleHTML, html)
})
t.Run("Referenced issue comment", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
html := extractHTML(t, session, issue1, "div.timeline > div.timeline-item:nth-child(3) > div.detail > * > a")
assertContainsAll(t, titleHTML, html)
})
t.Run("Dependent issue comment", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
err := issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2)
require.NoError(t, err)
html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(3) > div.detail > * > a")
assertContainsAll(t, titleHTML, html)
})
t.Run("Dependent issue sidebar", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
html := extractHTML(t, session, issue1, "div.item.dependency > * > a.title")
assertContainsAll(t, titleHTML, html)
})
t.Run("Referenced pull comment", func(t *testing.T) {
_, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader("Update README"),
},
},
Message: "Update README",
OldBranch: "main",
NewBranch: "branch",
Author: &files_service.IdentityOptions{
Name: user.Name,
Email: user.Email,
},
Committer: &files_service.IdentityOptions{
Name: user.Name,
Email: user.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
pullIssue := &issues_model.Issue{
RepoID: repo.ID,
Title: title,
Content: "Closes #1",
PosterID: user.ID,
Poster: user,
IsPull: true,
}
pullRequest := &issues_model.PullRequest{
HeadRepoID: repo.ID,
BaseRepoID: repo.ID,
HeadBranch: "branch",
BaseBranch: "main",
HeadRepo: repo,
BaseRepo: repo,
Type: issues_model.PullRequestGitea,
}
err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil)
require.NoError(t, err)
html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(4) > div.detail > * > a")
assertContainsAll(t, titleHTML, html)
})
})
}
func createIssue(t *testing.T, user *user_model.User, repo *repo_model.Repository, title, content string) *issues_model.Issue {
issue := &issues_model.Issue{
RepoID: repo.ID,
Title: title,
Content: content,
PosterID: user.ID,
Poster: user,
}
err := issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil)
require.NoError(t, err)
return issue
}
func extractHTML(t *testing.T, session *TestSession, issue *issues_model.Issue, query string) string {
req := NewRequest(t, "GET", issue.HTMLURL())
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
res, err := doc.doc.Find(query).Html()
require.NoError(t, err)
return res
}
func assertContainsAll(t *testing.T, expected []string, actual string) {
for i := range expected {
assert.Contains(t, actual, expected[i])
}
}

View file

@ -1,6 +1,7 @@
import {defineConfig} from 'vitest/config'; import {defineConfig} from 'vitest/config';
import vuePlugin from '@vitejs/plugin-vue'; import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin'; import {stringPlugin} from 'vite-string-plugin';
import {resolve} from 'node:path';
export default defineConfig({ export default defineConfig({
test: { test: {
@ -13,6 +14,9 @@ export default defineConfig({
passWithNoTests: true, passWithNoTests: true,
globals: true, globals: true,
watch: false, watch: false,
alias: {
'monaco-editor': resolve(import.meta.dirname, '/node_modules/monaco-editor/esm/vs/editor/editor.api'),
},
}, },
plugins: [ plugins: [
stringPlugin(), stringPlugin(),

View file

@ -8,6 +8,7 @@ import {toAbsoluteUrl} from '../utils.js';
import {initDropzone} from './common-global.js'; import {initDropzone} from './common-global.js';
import {POST, GET} from '../modules/fetch.js'; import {POST, GET} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js'; import {showErrorToast} from '../modules/toast.js';
import {emojiHTML} from './emoji.js';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
@ -124,7 +125,7 @@ export function initRepoIssueSidebarList() {
return; return;
} }
filteredResponse.results.push({ filteredResponse.results.push({
name: `#${issue.number} ${htmlEscape(issue.title) name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
}<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, }<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
value: issue.id, value: issue.id,
}); });
@ -731,3 +732,9 @@ export function initArchivedLabelHandler() {
toggleElem(label, label.classList.contains('checked')); toggleElem(label, label.classList.contains('checked'));
} }
} }
// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent.
export function issueTitleHTML(title) {
return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
.replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
}

View file

@ -0,0 +1,24 @@
import {vi} from 'vitest';
import {issueTitleHTML} from './repo-issue.js';
// monaco-editor does not have any exports fields, which trips up vitest
vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
// jQuery is missing
vi.mock('./common-global.js', () => ({}));
test('Convert issue title to html', () => {
expect(issueTitleHTML('')).toEqual('');
expect(issueTitleHTML('issue title')).toEqual('issue title');
const expected_thumbs_up = `<span class="emoji" title=":+1:">👍</span>`;
expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up);
expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:');
const expected_code_block = `<code class="inline-code-block">code</code>`;
expect(issueTitleHTML('`code`')).toEqual(expected_code_block);
expect(issueTitleHTML('`invalid code')).toEqual('`invalid code');
expect(issueTitleHTML('invalid code`')).toEqual('invalid code`');
expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`);
});