From 4a74113dee37ac3de2df27cef5c15e800b4f649d Mon Sep 17 00:00:00 2001 From: Bram Hagens <bram@bramh.me> Date: Wed, 17 Jul 2024 01:37:20 +0200 Subject: [PATCH] feat(ui): add more emoji and code block rendering in issues --- modules/markup/html.go | 49 ++++++ modules/templates/helper.go | 11 +- modules/templates/util_render.go | 11 ++ modules/templates/util_render_test.go | 40 ++++- templates/repo/issue/card.tmpl | 2 +- .../repo/issue/view_content/comments.tmpl | 14 +- .../view_content/sidebar/dependencies.tmpl | 13 +- templates/repo/issue/view_title.tmpl | 3 +- templates/repo/pulse.tmpl | 12 +- .../user/notification/notification_div.tmpl | 2 +- tests/integration/repo_issue_title_test.go | 162 ++++++++++++++++++ vitest.config.js | 4 + web_src/js/features/repo-issue.js | 9 +- web_src/js/features/repo-issue.test.js | 24 +++ 14 files changed, 322 insertions(+), 34 deletions(-) create mode 100644 tests/integration/repo_issue_title_test.go create mode 100644 web_src/js/features/repo-issue.test.js diff --git a/modules/markup/html.go b/modules/markup/html.go index 9280a67d62..2e65827bf7 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -73,6 +73,8 @@ var ( // EmojiShortCodeRegex find emoji by alias like :smile: EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`) + + InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`") ) // CSS class for action keywords (e.g. "closes: #1") @@ -243,6 +245,7 @@ func RenderIssueTitle( title string, ) (string, error) { return renderProcessString(ctx, []processor{ + inlineCodeBlockProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, @@ -251,6 +254,19 @@ func RenderIssueTitle( }, 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) { var buf strings.Builder if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { @@ -438,6 +454,24 @@ func createKeyword(content string) *html.Node { 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 { span := &html.Node{ 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 func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { start := 0 diff --git a/modules/templates/helper.go b/modules/templates/helper.go index f1ae1c8bb1..aeae8204ad 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -172,11 +172,12 @@ func NewFuncMap() template.FuncMap { "RenderCommitMessage": RenderCommitMessage, "RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject, - "RenderCommitBody": RenderCommitBody, - "RenderCodeBlock": RenderCodeBlock, - "RenderIssueTitle": RenderIssueTitle, - "RenderEmoji": RenderEmoji, - "ReactionToEmoji": ReactionToEmoji, + "RenderCommitBody": RenderCommitBody, + "RenderCodeBlock": RenderCodeBlock, + "RenderIssueTitle": RenderIssueTitle, + "RenderRefIssueTitle": RenderRefIssueTitle, + "RenderEmoji": RenderEmoji, + "ReactionToEmoji": ReactionToEmoji, "RenderMarkdownToHtml": RenderMarkdownToHtml, "RenderLabel": RenderLabel, diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 76790b63d5..c53bdd876f 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -130,6 +130,17 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) 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 // 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 { diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index ea01612ac3..da74298ef7 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -35,8 +35,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit mail@domain.com @mention-user test #123 - space -` + space +` + "`code :+1: #123 code`\n" var testMetas = map[string]string{ "user": "user13", @@ -115,8 +115,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit <a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a> <a href="/mention-user" class="mention">@mention-user</a> test <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)) } @@ -152,11 +152,38 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit mail@domain.com @mention-user test <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)) } +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 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) { expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/> /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="/mention-user" rel="nofollow">@mention-user</a> test #123 -space</p> +space +<code>code :+1: #123 code</code></p> ` assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput)) } diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl index 4c22c28329..0b255d6705 100644 --- a/templates/repo/issue/card.tmpl +++ b/templates/repo/issue/card.tmpl @@ -14,7 +14,7 @@ <div class="issue-card-icon"> {{template "shared/issueicon" .}} </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}} <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}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 019638bfb0..08c83c07d7 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -149,7 +149,7 @@ {{if eq .RefAction 3}}</del>{{end}} <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> {{else if eq .Type 4}} @@ -226,7 +226,7 @@ {{template "shared/user/avatarlink" dict "user" .Poster}} <span class="text grey muted-links"> {{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> </div> {{else if eq .Type 11}} @@ -339,10 +339,11 @@ {{svg "octicon-plus"}} <span class="text grey muted-links"> <a href="{{.DependentIssue.Link}}"> + {{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}} {{if eq .DependentIssue.RepoID .Issue.RepoID}} - #{{.DependentIssue.Index}} {{.DependentIssue.Title}} + #{{.DependentIssue.Index}} {{$strTitle}} {{else}} - {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}} + {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}} {{end}} </a> </span> @@ -362,10 +363,11 @@ {{svg "octicon-trash"}} <span class="text grey muted-links"> <a href="{{.DependentIssue.Link}}"> + {{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}} {{if eq .DependentIssue.RepoID .Issue.RepoID}} - #{{.DependentIssue.Index}} {{.DependentIssue.Title}} + #{{.DependentIssue.Index}} {{$strTitle}} {{else}} - {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}} + {{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}} {{end}} </a> </span> diff --git a/templates/repo/issue/view_content/sidebar/dependencies.tmpl b/templates/repo/issue/view_content/sidebar/dependencies.tmpl index 791bd5c4a1..6a9b651e7d 100644 --- a/templates/repo/issue/view_content/sidebar/dependencies.tmpl +++ b/templates/repo/issue/view_content/sidebar/dependencies.tmpl @@ -19,8 +19,8 @@ {{range .BlockingDependencies}} <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"> - <a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> - #{{.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> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> {{.Repository.OwnerName}}/{{.Repository.Name}} @@ -51,8 +51,9 @@ {{range .BlockedByDependencies}} <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"> - <a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}"> - #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + {{$title := RenderRefIssueTitle $.Context .Issue.Title}} + <a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}"> + #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}} </a> <div class="text small gt-ellipsis" data-tooltip-content="{{.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="gt-ellipsis"> <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}}"> - #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + <span class="title" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}"> + #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}} </span> </div> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl index db21adae2c..f60d958a1c 100644 --- a/templates/repo/issue/view_title.tmpl +++ b/templates/repo/issue/view_title.tmpl @@ -7,8 +7,7 @@ {{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}} <div class="issue-title" id="issue-title-display"> <h1 class="tw-break-anywhere"> - {{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}} - <span class="index">#{{.Issue.Index}}</span> + {{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx)}} <span class="index">#{{.Issue.Index}}</span> </h1> <div class="button-row"> {{if $canEditIssueTitle}} diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl index 0494883a85..3554cf6a19 100644 --- a/templates/repo/pulse.tmpl +++ b/templates/repo/pulse.tmpl @@ -153,7 +153,7 @@ {{range .Activity.MergedPRs}} <p class="desc"> <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}} </p> {{end}} @@ -172,7 +172,7 @@ {{range .Activity.OpenedPRs}} <p class="desc"> <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}} </p> {{end}} @@ -191,7 +191,7 @@ {{range .Activity.ClosedIssues}} <p class="desc"> <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}} </p> {{end}} @@ -210,7 +210,7 @@ {{range .Activity.OpenedIssues}} <p class="desc"> <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}} </p> {{end}} @@ -228,9 +228,9 @@ <span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span> #{{.Index}} {{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}} - <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> + <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a> {{end}} {{TimeSinceUnix .UpdatedUnix ctx.Locale}} </p> diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl index 4329ffdb7a..5c27ba8b41 100644 --- a/templates/user/notification/notification_div.tmpl +++ b/templates/user/notification/notification_div.tmpl @@ -58,7 +58,7 @@ <div class="notifications-bottom-row tw-text-16 tw-py-0.5"> <span class="issue-title tw-break-anywhere"> {{if .Issue}} - {{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}} + {{RenderRefIssueTitle $.Context .Issue.Title}} {{else}} {{.Repository.FullName}} {{end}} diff --git a/tests/integration/repo_issue_title_test.go b/tests/integration/repo_issue_title_test.go new file mode 100644 index 0000000000..5199be91bc --- /dev/null +++ b/tests/integration/repo_issue_title_test.go @@ -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]) + } +} diff --git a/vitest.config.js b/vitest.config.js index ea0fafeee8..776247c5ee 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,6 +1,7 @@ import {defineConfig} from 'vitest/config'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; +import {resolve} from 'node:path'; export default defineConfig({ test: { @@ -13,6 +14,9 @@ export default defineConfig({ passWithNoTests: true, globals: true, watch: false, + alias: { + 'monaco-editor': resolve(import.meta.dirname, '/node_modules/monaco-editor/esm/vs/editor/editor.api'), + }, }, plugins: [ stringPlugin(), diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index ff8faa94f6..feba449c16 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -8,6 +8,7 @@ import {toAbsoluteUrl} from '../utils.js'; import {initDropzone} from './common-global.js'; import {POST, GET} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; +import {emojiHTML} from './emoji.js'; const {appSubUrl} = window.config; @@ -124,7 +125,7 @@ export function initRepoIssueSidebarList() { return; } 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>`, value: issue.id, }); @@ -731,3 +732,9 @@ export function initArchivedLabelHandler() { 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>`); +} diff --git a/web_src/js/features/repo-issue.test.js b/web_src/js/features/repo-issue.test.js new file mode 100644 index 0000000000..8c9734b0c6 --- /dev/null +++ b/web_src/js/features/repo-issue.test.js @@ -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}`); +});