mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-01 13:44:06 +01:00
Merge pull request 'feat(ui): add more emoji and code block rendering in issues' (#4541) from bramh/forgejo:consistent-title-formatting into forgejo
Some checks are pending
/ release (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-remote-cacher (map[image:docker.io/bitnami/redis:7.2 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:docker.io/bitnami/valkey:7.2 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:ghcr.io/microsoft/garnet-alpine:1.0.14 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:registry.redict.io/redict:7.3.0-scratch port:6379]) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Some checks are pending
/ release (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-remote-cacher (map[image:docker.io/bitnami/redis:7.2 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:docker.io/bitnami/valkey:7.2 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:ghcr.io/microsoft/garnet-alpine:1.0.14 port:6379]) (push) Blocked by required conditions
testing / test-remote-cacher (map[image:registry.redict.io/redict:7.3.0-scratch port:6379]) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
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 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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}">
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
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 {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(),
|
||||||
|
|
|
@ -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>`);
|
||||||
|
}
|
||||||
|
|
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…
Reference in a new issue