mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-10-31 14:31:02 +00:00 
			
		
		
		
	Merge pull request 'feat(ui): add more emoji and code block rendering in issues' (#4541) from bramh/forgejo:consistent-title-formatting into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4541 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
		
				commit
				
					
						1b528a7874
					
				
			
		
					 14 changed files with 322 additions and 34 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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}} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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}}"> | ||||
|  |  | |||
|  | @ -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}} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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}} | ||||
|  |  | |||
							
								
								
									
										162
									
								
								tests/integration/repo_issue_title_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								tests/integration/repo_issue_title_test.go
									
										
									
									
									
										Normal 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]) | ||||
| 	} | ||||
| } | ||||
|  | @ -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(), | ||||
|  |  | |||
|  | @ -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>`); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										24
									
								
								web_src/js/features/repo-issue.test.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								web_src/js/features/repo-issue.test.js
									
										
									
									
									
										Normal 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}`); | ||||
| }); | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue