diff --git a/modules/markup/html.go b/modules/markup/html.go index 9280a67d6..2e65827bf 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 f1ae1c8bb..aeae8204a 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 76790b63d..c53bdd876 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 ea01612ac..da74298ef 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 mail@domain.com @mention-user test #123 - space` - + space +` + "`code 👍 #123 code`" assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas)) } @@ -152,11 +152,38 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit mail@domain.com @mention-user test #123 - space + space +code :+1: #123 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 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 +👍 +mail@domain.com +@mention-user test +#123 + space +code :+1: #123 code +` + assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput)) +} + func TestRenderMarkdownToHtml(t *testing.T) { expected := `

space @mention-user
/just/a/path.bin @@ -177,7 +204,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit mail@domain.com @mention-user test #123 -space

+space +code :+1: #123 code

` assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput)) } diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl index 4c22c2832..0b255d670 100644 --- a/templates/repo/issue/card.tmpl +++ b/templates/repo/issue/card.tmpl @@ -14,7 +14,7 @@
{{template "shared/issueicon" .}}
- {{.Title | RenderEmoji ctx | RenderCodeBlock}} + {{RenderRefIssueTitle $.Context .Title}} {{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}} {{svg "octicon-x" 16}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 019638bfb..08c83c07d 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}}{{end}}
- {{.RefIssueTitle ctx}} {{.RefIssueIdent ctx}} + {{.RefIssueTitle ctx | RenderEmoji $.Context | RenderCodeBlock}} {{.RefIssueIdent ctx}}
{{else if eq .Type 4}} @@ -226,7 +226,7 @@ {{template "shared/user/avatarlink" dict "user" .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}} {{else if eq .Type 11}} @@ -339,10 +339,11 @@ {{svg "octicon-plus"}} + {{$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}} @@ -362,10 +363,11 @@ {{svg "octicon-trash"}} + {{$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}} diff --git a/templates/repo/issue/view_content/sidebar/dependencies.tmpl b/templates/repo/issue/view_content/sidebar/dependencies.tmpl index 791bd5c4a..6a9b651e7 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}}
- - #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + + #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}
{{.Repository.OwnerName}}/{{.Repository.Name}} @@ -51,8 +51,9 @@ {{range .BlockedByDependencies}}
- - #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + {{$title := RenderRefIssueTitle $.Context .Issue.Title}} + + #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}
{{.Repository.OwnerName}}/{{.Repository.Name}} @@ -73,8 +74,8 @@
{{svg "octicon-lock" 16}} - - #{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}} + + #{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}
diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl index db21adae2..f60d958a1 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)}}

- {{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}} - #{{.Issue.Index}} + {{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx)}} #{{.Issue.Index}}

{{if $canEditIssueTitle}} diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl index 0494883a8..3554cf6a1 100644 --- a/templates/repo/pulse.tmpl +++ b/templates/repo/pulse.tmpl @@ -153,7 +153,7 @@ {{range .Activity.MergedPRs}}

{{ctx.Locale.Tr "repo.activity.merged_prs_label"}} - #{{.Index}} {{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}} + #{{.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}} {{TimeSinceUnix .MergedUnix ctx.Locale}}

{{end}} @@ -172,7 +172,7 @@ {{range .Activity.OpenedPRs}}

{{ctx.Locale.Tr "repo.activity.opened_prs_label"}} - #{{.Index}} {{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}} + #{{.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}} {{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}

{{end}} @@ -191,7 +191,7 @@ {{range .Activity.ClosedIssues}}

{{ctx.Locale.Tr "repo.activity.closed_issue_label"}} - #{{.Index}} {{.Title | RenderEmoji $.Context | RenderCodeBlock}} + #{{.Index}} {{RenderRefIssueTitle $.Context .Title}} {{TimeSinceUnix .ClosedUnix ctx.Locale}}

{{end}} @@ -210,7 +210,7 @@ {{range .Activity.OpenedIssues}}

{{ctx.Locale.Tr "repo.activity.new_issue_label"}} - #{{.Index}} {{.Title | RenderEmoji $.Context | RenderCodeBlock}} + #{{.Index}} {{RenderRefIssueTitle $.Context .Title}} {{TimeSinceUnix .CreatedUnix ctx.Locale}}

{{end}} @@ -228,9 +228,9 @@ {{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}} #{{.Index}} {{if .IsPull}} - {{.Title | RenderEmoji $.Context | RenderCodeBlock}} + {{RenderRefIssueTitle $.Context .Title}} {{else}} - {{.Title | RenderEmoji $.Context | RenderCodeBlock}} + {{RenderRefIssueTitle $.Context .Title}} {{end}} {{TimeSinceUnix .UpdatedUnix ctx.Locale}}

diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl index 4329ffdb7..5c27ba8b4 100644 --- a/templates/user/notification/notification_div.tmpl +++ b/templates/user/notification/notification_div.tmpl @@ -58,7 +58,7 @@
{{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 000000000..5199be91b --- /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", + `👍`, + `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 ea0fafeee..776247c5e 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 ff8faa94f..feba449c1 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)) }
${htmlEscape(issue.repository.full_name)}
`, 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.substring(1, code.length - 1)}`); +} 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 000000000..8c9734b0c --- /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 = `👍`; + expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up); + expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:'); + + const expected_code_block = `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}`); +});