This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func AssetFS() *assetfs.LayeredFS {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type Num struct {
|
||||
|
||||
@@ -6,23 +6,21 @@ package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/public"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/templates/eval"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/gitdiff"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/templates/eval"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/gitdiff"
|
||||
)
|
||||
|
||||
func newFuncMapWebPage() template.FuncMap {
|
||||
@@ -32,13 +30,12 @@ func newFuncMapWebPage() template.FuncMap {
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// html/template related functions
|
||||
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
|
||||
"Iif": iif,
|
||||
"Eval": evalTokens,
|
||||
"HTMLFormat": htmlFormat,
|
||||
"QueryEscape": queryEscape,
|
||||
"QueryBuild": QueryBuild,
|
||||
"SanitizeHTML": SanitizeHTML,
|
||||
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
|
||||
"Iif": iif,
|
||||
"Eval": evalTokens,
|
||||
"HTMLFormat": htmlFormat,
|
||||
"QueryEscape": queryEscape,
|
||||
"QueryBuild": QueryBuild,
|
||||
|
||||
"PathEscape": url.PathEscape,
|
||||
"PathEscapeSegments": util.PathEscapeSegments,
|
||||
@@ -70,8 +67,8 @@ func newFuncMapWebPage() template.FuncMap {
|
||||
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
|
||||
},
|
||||
|
||||
"AssetURI": public.AssetURI,
|
||||
"ScriptImport": scriptImport,
|
||||
"AssetURI": public.AssetURI,
|
||||
"AssetCSSLinks": public.AssetCSSLinks,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// setting
|
||||
@@ -136,19 +133,17 @@ func newFuncMapWebPage() template.FuncMap {
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
|
||||
"ActionContent2Commits": ActionContent2Commits,
|
||||
"IsMultilineCommitMessage": isMultilineCommitMessage,
|
||||
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
|
||||
"MirrorRemoteAddress": mirrorRemoteAddress,
|
||||
"ActionContent2Commits": ActionContent2Commits,
|
||||
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
|
||||
"MirrorRemoteAddress": mirrorRemoteAddress,
|
||||
|
||||
"FilenameIsImage": filenameIsImage,
|
||||
"TabSizeClass": tabSizeClass,
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeHTML sanitizes the input by default sanitization rules.
|
||||
func SanitizeHTML(s string) template.HTML {
|
||||
return markup.Sanitize(s)
|
||||
func sanitizeHTML(msg string) template.HTML {
|
||||
return markup.Sanitize(msg)
|
||||
}
|
||||
|
||||
func htmlFormat(s any, args ...any) template.HTML {
|
||||
@@ -292,30 +287,3 @@ func QueryBuild(a ...any) template.URL {
|
||||
}
|
||||
return template.URL(s)
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
scriptImportRemainingPart string
|
||||
},
|
||||
) {
|
||||
// add onerror handler to alert users when the script fails to load:
|
||||
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
|
||||
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
|
||||
// the message will be directly put in the onerror JS code's string
|
||||
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
|
||||
if !setting.IsProd {
|
||||
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
|
||||
}
|
||||
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
|
||||
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
|
||||
return ret
|
||||
})
|
||||
|
||||
func scriptImport(path string, typ ...string) template.HTML {
|
||||
if len(typ) > 0 {
|
||||
if typ[0] == "module" {
|
||||
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||
}
|
||||
panic("unsupported script type: " + typ[0])
|
||||
}
|
||||
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -59,7 +59,7 @@ func TestSubjectBodySeparator(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeHTML(t *testing.T) {
|
||||
assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
|
||||
assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), sanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
|
||||
}
|
||||
|
||||
func TestTemplateIif(t *testing.T) {
|
||||
@@ -92,9 +92,12 @@ func TestTemplateEscape(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("Golang URL Escape", func(t *testing.T) {
|
||||
// Golang template considers "href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: demo cases (html/template/attr.go):
|
||||
// Golang template considers "href", "data-href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
|
||||
actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
|
||||
actual = execTmpl(`<a data-href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-href="?a=%25"></a>`, actual)
|
||||
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
|
||||
})
|
||||
@@ -102,6 +105,10 @@ func TestTemplateEscape(t *testing.T) {
|
||||
// non-URL content isn't auto-escaped
|
||||
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
|
||||
// the attr names like "data-href" and "data-action" are treated as URL (as the "data-" prefix is stripped)
|
||||
// but "data-xxx-href" and "data-xxx-action" are not, so no escaping.
|
||||
actual = execTmpl(`<a data-xxx-href="?a={{"%"}}"></a>`)
|
||||
assert.Equal(t, `<a data-xxx-href="?a=%"></a>`, actual)
|
||||
})
|
||||
t.Run("QueryBuild", func(t *testing.T) {
|
||||
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
"sync/atomic"
|
||||
texttemplate "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates/scopedtmpl"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates/scopedtmpl"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type TemplateExecutor scopedtmpl.TemplateExecutor
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"gitea.dev/modules/assetfs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"sync"
|
||||
texttmpl "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type MailRender struct {
|
||||
@@ -65,13 +65,16 @@ func mailBodyFuncMap() template.FuncMap {
|
||||
"NIL": func() any { return nil },
|
||||
|
||||
// html/template related functions
|
||||
"dict": dict,
|
||||
"Iif": iif,
|
||||
"Eval": evalTokens,
|
||||
"HTMLFormat": htmlFormat,
|
||||
"QueryEscape": queryEscape,
|
||||
"QueryBuild": QueryBuild,
|
||||
"SanitizeHTML": SanitizeHTML,
|
||||
"dict": dict,
|
||||
"Iif": iif,
|
||||
"Eval": evalTokens,
|
||||
"HTMLFormat": htmlFormat,
|
||||
"QueryEscape": queryEscape,
|
||||
"QueryBuild": QueryBuild,
|
||||
|
||||
// deprecated, use "HTMLFormat" instead, but some user custom mail templates still use it
|
||||
// see: https://github.com/go-gitea/gitea/issues/36049
|
||||
"SanitizeHTML": sanitizeHTML,
|
||||
|
||||
"PathEscape": url.PathEscape,
|
||||
"PathEscapeSegments": util.PathEscapeSegments,
|
||||
|
||||
@@ -5,6 +5,7 @@ package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,10 +14,10 @@ import (
|
||||
"sync"
|
||||
texttemplate "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type pageRenderer struct {
|
||||
@@ -36,7 +37,11 @@ func (r *pageRenderer) funcMapDummy() template.FuncMap {
|
||||
}
|
||||
|
||||
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
|
||||
return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx))
|
||||
tmpls := r.tmplRenderer.Templates()
|
||||
if tmpls == nil {
|
||||
return nil, fmt.Errorf("no templates defined for %s", tmpl)
|
||||
}
|
||||
return tmpls.Executor(tmpl, r.funcMap(templateCtx))
|
||||
}
|
||||
|
||||
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
|
||||
|
||||
@@ -54,17 +54,14 @@ func TestScopedTemplateSetFuncMap(t *testing.T) {
|
||||
out1 := bytes.Buffer{}
|
||||
out2 := bytes.Buffer{}
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
wg.Go(func() {
|
||||
err := ts.newExecutor(funcMap1).Execute(&out1, nil)
|
||||
assert.NoError(t, err)
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
})
|
||||
wg.Go(func() {
|
||||
err := ts.newExecutor(funcMap2).Execute(&out2, nil)
|
||||
assert.NoError(t, err)
|
||||
wg.Done()
|
||||
}()
|
||||
})
|
||||
wg.Wait()
|
||||
assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String())
|
||||
assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String())
|
||||
|
||||
@@ -10,9 +10,9 @@ package templates
|
||||
import (
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
"gitea.dev/modules/assetfs"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed bindata.dat
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func BuiltinAssets() *assetfs.Layer {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
)
|
||||
|
||||
type ActionsUtils struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewActionsUtils(ctx context.Context) *ActionsUtils {
|
||||
return &ActionsUtils{ctx: ctx}
|
||||
}
|
||||
|
||||
func (a *ActionsUtils) CommitStatusesToActionsStatuses(statuses []*git_model.CommitStatus) actions_module.CommitActionsStatusMap {
|
||||
return actions_module.GetCommitActionsStatusMap(a.ctx, statuses)
|
||||
}
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/avatars"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
gitea_html "code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/avatars"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
gitea_html "gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type AvatarUtils struct {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
type DateUtils struct{}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"html/template"
|
||||
"reflect"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func dictMerge(base map[string]any, arg any) bool {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func timeEstimateString(timeSec any) string {
|
||||
|
||||
@@ -6,7 +6,7 @@ package templates
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"gitea.dev/modules/json"
|
||||
)
|
||||
|
||||
type JsonUtils struct{} //nolint:revive // variable naming triggers on Json, wants JSON
|
||||
|
||||
@@ -12,15 +12,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/editorconfig/editorconfig-core-go/v2"
|
||||
)
|
||||
@@ -52,11 +52,6 @@ func sortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML
|
||||
return ""
|
||||
}
|
||||
|
||||
// isMultilineCommitMessage checks to see if a commit message contains multiple lines.
|
||||
func isMultilineCommitMessage(msg string) bool {
|
||||
return strings.Count(strings.TrimSpace(msg), "\n") >= 1
|
||||
}
|
||||
|
||||
// Actioner describes an action
|
||||
type Actioner interface {
|
||||
GetOpType() activities_model.ActionType
|
||||
|
||||
@@ -10,93 +10,68 @@ import (
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/webtheme"
|
||||
user_model "gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
"gitea.dev/models/repo"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/webtheme"
|
||||
)
|
||||
|
||||
type RenderUtils struct {
|
||||
ctx reqctx.RequestContext
|
||||
ctx reqctx.RequestContext
|
||||
avatarUtils *AvatarUtils
|
||||
}
|
||||
|
||||
func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
|
||||
return &RenderUtils{ctx: ctx}
|
||||
return &RenderUtils{ctx: ctx, avatarUtils: NewAvatarUtils(ctx)}
|
||||
}
|
||||
|
||||
// RenderCommitMessage renders commit message with XSS-safe and special links.
|
||||
// RenderCommitMessage renders commit message title (only title)
|
||||
func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML {
|
||||
cleanMsg := template.HTMLEscapeString(msg)
|
||||
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
||||
// "repo" can be nil when rendering commit messages for deleted repositories in a user's dashboard feed.
|
||||
fullMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), cleanMsg)
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessage: %v", err)
|
||||
return ""
|
||||
}
|
||||
msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n")
|
||||
if len(msgLines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderCodeBlock(template.HTML(msgLines[0]))
|
||||
msgLine := strings.TrimSpace(msg)
|
||||
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
||||
msgLine = strings.TrimSpace(msgLine)
|
||||
rendered := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlutil.EscapeString(msgLine))
|
||||
return renderCodeBlock(rendered)
|
||||
}
|
||||
|
||||
// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
|
||||
// the provided default url, handling for special links without email to links.
|
||||
func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML {
|
||||
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
|
||||
lineEnd := strings.IndexByte(msgLine, '\n')
|
||||
if lineEnd > 0 {
|
||||
msgLine = msgLine[:lineEnd]
|
||||
}
|
||||
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
|
||||
if len(msgLine) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
||||
renderedMessage, err := markup.PostProcessCommitMessageSubject(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), urlDefault, template.HTMLEscapeString(msgLine))
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessageSubject: %v", err)
|
||||
return ""
|
||||
}
|
||||
return renderCodeBlock(template.HTML(renderedMessage))
|
||||
msgLine := strings.TrimSpace(msg)
|
||||
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
||||
msgLine = strings.TrimSpace(msgLine)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
||||
rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, htmlutil.EscapeString(msgLine))
|
||||
return renderCodeBlock(rendered)
|
||||
}
|
||||
|
||||
// RenderCommitBody extracts the body of a commit message without its title.
|
||||
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
|
||||
msgLine := strings.TrimSpace(msg)
|
||||
lineEnd := strings.IndexByte(msgLine, '\n')
|
||||
if lineEnd > 0 {
|
||||
msgLine = msgLine[lineEnd+1:]
|
||||
} else {
|
||||
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace)
|
||||
if len(msgLine) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
renderedMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), template.HTMLEscapeString(msgLine))
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessage: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(renderedMessage)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
||||
renderedMessage := markup.PostProcessCommitMessage(rctx, htmlutil.EscapeString(body))
|
||||
return renderedMessage
|
||||
}
|
||||
|
||||
// Match text that is between back ticks.
|
||||
@@ -110,38 +85,19 @@ func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
|
||||
|
||||
// RenderIssueTitle renders issue/pull title with defined post processors
|
||||
func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML {
|
||||
renderedText, err := markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), template.HTMLEscapeString(text))
|
||||
if err != nil {
|
||||
log.Error("PostProcessIssueTitle: %v", err)
|
||||
return ""
|
||||
}
|
||||
return renderCodeBlock(template.HTML(renderedText))
|
||||
// wrap "`…`" in <code> before post-processing so code-span content stays literal, like comment bodies
|
||||
htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
|
||||
return markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlWithCode)
|
||||
}
|
||||
|
||||
// RenderIssueSimpleTitle only renders with emoji and inline code block
|
||||
func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
|
||||
ret := ut.RenderEmoji(text)
|
||||
ret = renderCodeBlock(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderLabelWithLink(label *issues_model.Label, link any) template.HTML {
|
||||
var attrHref template.HTML
|
||||
switch link.(type) {
|
||||
case template.URL, string:
|
||||
attrHref = htmlutil.HTMLFormat(`href="%s"`, link)
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected type %T for link", link))
|
||||
}
|
||||
return ut.renderLabelWithTag(label, "a", attrHref)
|
||||
// see RenderIssueTitle: wrap code spans before processing emoji
|
||||
htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
|
||||
return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlWithCode)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
|
||||
return ut.renderLabelWithTag(label, "span", "")
|
||||
}
|
||||
|
||||
// RenderLabel renders a label
|
||||
func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, tagAttrs template.HTML) template.HTML {
|
||||
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
|
||||
var extraCSSClasses string
|
||||
textColor := util.ContrastColor(label.Color)
|
||||
@@ -155,8 +111,8 @@ func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, ta
|
||||
|
||||
if labelScope == "" {
|
||||
// Regular label
|
||||
return htmlutil.HTMLFormat(`<%s %s class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></%s>`,
|
||||
tagName, tagAttrs, extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name), tagName)
|
||||
return htmlutil.HTMLFormat(`<span class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></span>`,
|
||||
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
|
||||
}
|
||||
|
||||
// Scoped label
|
||||
@@ -191,39 +147,30 @@ func (ut *RenderUtils) renderLabelWithTag(label *issues_model.Label, tagName, ta
|
||||
|
||||
if label.ExclusiveOrder > 0 {
|
||||
// <scope> | <label> | <order>
|
||||
return htmlutil.HTMLFormat(`<%s %s class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
||||
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
||||
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
|
||||
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
|
||||
`<div class="ui label scope-right">%d</div>`+
|
||||
`</%s>`,
|
||||
tagName, tagAttrs,
|
||||
`</span>`,
|
||||
extraCSSClasses, descriptionText,
|
||||
textColor, scopeColor, scopeHTML,
|
||||
textColor, itemColor, itemHTML,
|
||||
label.ExclusiveOrder,
|
||||
tagName)
|
||||
label.ExclusiveOrder)
|
||||
}
|
||||
|
||||
// <scope> | <label>
|
||||
return htmlutil.HTMLFormat(`<%s %s class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
||||
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
||||
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
|
||||
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
|
||||
`</%s>`,
|
||||
tagName, tagAttrs,
|
||||
`</span>`,
|
||||
extraCSSClasses, descriptionText,
|
||||
textColor, scopeColor, scopeHTML,
|
||||
textColor, itemColor, itemHTML,
|
||||
tagName)
|
||||
textColor, itemColor, itemHTML)
|
||||
}
|
||||
|
||||
// RenderEmoji renders html text with emoji post processors
|
||||
func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
|
||||
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))
|
||||
if err != nil {
|
||||
log.Error("RenderEmoji: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(renderedText)
|
||||
return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlutil.EscapeString(text))
|
||||
}
|
||||
|
||||
// reactionToEmoji renders emoji for use in reactions
|
||||
@@ -247,21 +194,41 @@ func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:rev
|
||||
return output
|
||||
}
|
||||
|
||||
// RenderPackageMarkdown renders package page Markdown so relative links resolve against the
|
||||
// linked repository's default branch instead of the site root, falling back to plain rendering
|
||||
// when there is no linked repository. pkgTreePath optionally roots links in a subdirectory
|
||||
// (e.g. npm's repository.directory for monorepo packages).
|
||||
func (ut *RenderUtils) RenderPackageMarkdown(input string, linkedRepo *repo.Repository, pkgTreePath ...string) template.HTML {
|
||||
if linkedRepo == nil {
|
||||
return `<div class="markup markdown">` + ut.MarkdownToHtml(input) + `</div>`
|
||||
}
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ut.ctx, linkedRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: git.RefNameFromBranch(linkedRepo.DefaultBranch).RefWebLinkPath(),
|
||||
CurrentTreePath: util.OptionalArg(pkgTreePath),
|
||||
})
|
||||
output, err := markdown.RenderString(rctx, input)
|
||||
if err != nil {
|
||||
log.Error("RenderString: %v", err)
|
||||
}
|
||||
return `<div class="markup markdown">` + output + `</div>`
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
|
||||
isPullRequest := issue != nil && issue.IsPull
|
||||
baseLink := fmt.Sprintf("%s/%s", repoLink, util.Iif(isPullRequest, "pulls", "issues"))
|
||||
var htmlCode strings.Builder
|
||||
htmlCode.WriteString(`<span class="labels-list">`)
|
||||
var htmlCode htmlutil.HTMLBuilder
|
||||
htmlCode.WriteHTML(`<span class="labels-list">`)
|
||||
for _, label := range labels {
|
||||
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
|
||||
if label == nil {
|
||||
continue
|
||||
}
|
||||
link := fmt.Sprintf("%s?labels=%d", baseLink, label.ID)
|
||||
htmlCode.WriteString(string(ut.RenderLabelWithLink(label, template.URL(link))))
|
||||
htmlCode.WriteFormat(`<a class="item" href="%s?labels=%d">`, baseLink, label.ID)
|
||||
htmlCode.WriteHTML(ut.RenderLabel(label))
|
||||
htmlCode.WriteHTML("</a>")
|
||||
}
|
||||
htmlCode.WriteString("</span>")
|
||||
return template.HTML(htmlCode.String())
|
||||
htmlCode.WriteHTML("</span>")
|
||||
return htmlCode.HTMLString()
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML {
|
||||
@@ -279,6 +246,35 @@ func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize in
|
||||
return htmlutil.HTMLFormat(`<div class="theme-menu-item" data-tooltip-content="%s">%s %s %s</div>`, info.GetDescription(), icon, info.DisplayName, extraIcon)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderFlashMessage(typ, msg string) template.HTML {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cls := typ
|
||||
// legacy logic: "negative" for error, "positive" for success
|
||||
switch cls {
|
||||
case "error":
|
||||
cls = "negative"
|
||||
case "success":
|
||||
cls = "positive"
|
||||
}
|
||||
|
||||
var msgContent template.HTML
|
||||
if strings.Contains(msg, "</pre>") || strings.Contains(msg, "</details>") || strings.Contains(msg, "</ul>") || strings.Contains(msg, "</div>") {
|
||||
// If the message contains some known "block" elements, no need to do more alignment or line-break processing, just sanitize it directly.
|
||||
msgContent = sanitizeHTML(msg)
|
||||
} else if !strings.Contains(msg, "\n") {
|
||||
// If the message is a single line, center-align it by wrapping it
|
||||
msgContent = htmlutil.HTMLFormat(`<div class="tw-text-center">%s</div>`, sanitizeHTML(msg))
|
||||
} else {
|
||||
// For a multi-line message, preserve line breaks, and left-align it.
|
||||
msgContent = htmlutil.HTMLFormat(`%s`, sanitizeHTML(strings.ReplaceAll(msg, "\n", "<br>")))
|
||||
}
|
||||
return htmlutil.HTMLFormat(`<div class="ui %s message flash-message flash-%s">%s</div>`, cls, typ, msgContent)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderUnicodeEscapeToggleButton(escapeStatus *charset.EscapeStatus) template.HTML {
|
||||
if escapeStatus == nil || !escapeStatus.Escaped {
|
||||
return ""
|
||||
@@ -299,3 +295,134 @@ func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *chars
|
||||
}
|
||||
return `<td class="lines-escape">` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `</td>`
|
||||
}
|
||||
|
||||
func renderAvatarStackViewEmailLink(data *user_model.AvatarStackData, email string) template.URL {
|
||||
if data.SearchByEmailLink != "" && email != "" {
|
||||
return template.URL(strings.ReplaceAll(data.SearchByEmailLink, "{email}", url.QueryEscape(email)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantHref(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.URL {
|
||||
if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" {
|
||||
return href
|
||||
}
|
||||
if participant.GiteaUser != nil {
|
||||
return template.URL(participant.GiteaUser.HomeLink())
|
||||
} else if participant.GitIdentity.Email != "" {
|
||||
return template.URL("mailto:" + participant.GitIdentity.Email)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantAvatar(participant *user_model.CommitParticipant) template.HTML {
|
||||
if participant.GiteaUser != nil {
|
||||
return ut.avatarUtils.Avatar(participant.GiteaUser, 20)
|
||||
}
|
||||
return ut.avatarUtils.AvatarByEmail(participant.GitIdentity.Email, participant.GitIdentity.Name, 20)
|
||||
}
|
||||
|
||||
func participantName(participant *user_model.CommitParticipant) string {
|
||||
if participant.GiteaUser != nil {
|
||||
return participant.GiteaUser.GetDisplayName()
|
||||
}
|
||||
return participant.GitIdentity.Name
|
||||
}
|
||||
|
||||
const renderAvatarStackMaxVisible = 10
|
||||
|
||||
// AvatarStack renders overlapping avatars for the stack participants. It emits children in reverse
|
||||
// so CSS `flex-direction: row-reverse` places the primary (Participants[0]) leftmost and last-painted (on top).
|
||||
func (ut *RenderUtils) AvatarStack(data *user_model.AvatarStackData) template.HTML {
|
||||
visible := data.Participants
|
||||
overflow := len(visible) - renderAvatarStackMaxVisible
|
||||
if overflow > 0 {
|
||||
visible = visible[:renderAvatarStackMaxVisible]
|
||||
}
|
||||
|
||||
var b htmlutil.HTMLBuilder
|
||||
b.WriteHTML(`<span class="avatar-stack">`)
|
||||
if overflow > 0 {
|
||||
b.WriteFormat(`<span class="avatar-stack-overflow-chip tw-text-xs" aria-label="+%d more">+%d</span>`, overflow, overflow)
|
||||
}
|
||||
|
||||
// FIXME: such "backward" breaks a11y like screen readers
|
||||
for _, participant := range slices.Backward(visible) {
|
||||
ut.writeAvatarStackItem(&b, data, participant)
|
||||
}
|
||||
b.WriteHTML(`</span>`)
|
||||
return b.HTMLString()
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) writeAvatarStackItem(b *htmlutil.HTMLBuilder, data *user_model.AvatarStackData, participant *user_model.CommitParticipant) {
|
||||
avatar := ut.participantAvatar(participant)
|
||||
if href := ut.participantHref(data, participant); href != "" {
|
||||
b.WriteFormat(`<a href="%s">%s</a>`, href, avatar)
|
||||
} else {
|
||||
b.WriteFormat(`<span>%s</span>`, avatar)
|
||||
}
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit) template.HTML {
|
||||
fakeGitCommit := git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: pushCommit.Message},
|
||||
Author: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||
// there is no way to know the real committer, but the field can't be nil
|
||||
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||
}
|
||||
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllAuthorIdentities(), nil)
|
||||
return ut.AvatarStack(data)
|
||||
}
|
||||
|
||||
// AvatarStackWithNames renders the avatar stack plus a label: `name` / `a and b` / `N people` (opens popup).
|
||||
func (ut *RenderUtils) AvatarStackWithNames(data *user_model.AvatarStackData) template.HTML {
|
||||
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
|
||||
participants := data.Participants
|
||||
|
||||
var b htmlutil.HTMLBuilder
|
||||
b.WriteHTML(`<span class="avatar-stack-names">`)
|
||||
b.WriteHTML(ut.AvatarStack(data))
|
||||
|
||||
switch len(participants) {
|
||||
case 1:
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[0]))
|
||||
case 2:
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[0]))
|
||||
b.WriteFormat(`<span>%s</span>`, locale.Tr("repo.commits.avatar_stack_and"))
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[1]))
|
||||
default:
|
||||
b.WriteFormat(`<button type="button" class="avatar-stack-popup-trigger" data-global-init="initAvatarStackPopup">%s</button>`,
|
||||
locale.Tr("repo.commits.avatar_stack_people", len(participants)))
|
||||
b.WriteHTML(`<div class="tippy-target"><div class="avatar-stack-popup">`)
|
||||
for _, participant := range participants {
|
||||
b.WriteHTML(ut.participantPopupRow(data, participant))
|
||||
}
|
||||
b.WriteHTML(`</div></div>`)
|
||||
}
|
||||
|
||||
b.WriteHTML(`</span>`)
|
||||
return b.HTMLString()
|
||||
}
|
||||
|
||||
// participantNameLink prefers (in order): commits-by-author search, `GetShortDisplayNameLinkHTML` (keeps alt-name tooltip), `mailto:`, bare name.
|
||||
func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML {
|
||||
if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="%s">%s</a>`, href, participantName(participant))
|
||||
}
|
||||
if participant.GiteaUser != nil {
|
||||
return participant.GiteaUser.GetShortDisplayNameLinkHTML()
|
||||
}
|
||||
if participant.GitIdentity.Email != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="mailto:%s">%s</a>`, participant.GitIdentity.Email, participant.GitIdentity.Name)
|
||||
}
|
||||
return htmlutil.HTMLFormat(`<span class="avatar-stack-name">%s</span>`, participant.GitIdentity.Name)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantPopupRow(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML {
|
||||
avatar := ut.participantAvatar(participant)
|
||||
name := participantName(participant)
|
||||
if href := ut.participantHref(data, participant); href != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="silenced flex-text-block" href="%s">%s<span>%s</span></a>`, href, avatar, name)
|
||||
}
|
||||
return htmlutil.HTMLFormat(`<span class="flex-text-block">%s<span>%s</span></span>`, avatar, name)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func commentTimelineEventIsWipToggle(c *issues_model.Comment) (isToggle, isWip bool) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -7,17 +7,21 @@ import (
|
||||
"context"
|
||||
"html/template"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"gitea.dev/models/gituser"
|
||||
"gitea.dev/models/issues"
|
||||
"gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/setting/config"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -131,15 +135,27 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessage", func(t *testing.T) {
|
||||
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> `
|
||||
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) {
|
||||
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
||||
// a bare URL in the subject must not hijack the default link
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">https://example.com/file.bin</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
||||
// a URL embedded in larger subject text still becomes its own link
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted title-full-link"> here</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderIssueTitle", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
expected := ` space @mention-user<SPACE><SPACE>
|
||||
@@ -168,6 +184,38 @@ mail@domain.com
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderIssueTitleCodeSpan(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
mockRepo := &repo.Repository{
|
||||
ID: 1, OwnerName: "user13", Name: "repo11",
|
||||
Owner: &user_model.User{ID: 13, Name: "user13"},
|
||||
Units: []*repo.RepoUnit{},
|
||||
}
|
||||
ut := newTestRenderUtils(t)
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
emojiSafe bool
|
||||
}{
|
||||
{"foo `:100:`", `foo <code class="inline-code-block">:100:</code>`, true},
|
||||
{"`#123`", `<code class="inline-code-block">#123</code>`, false},
|
||||
{"`88fc37a3c0a4dda553bdcfc80c178a58247f42fb`", `<code class="inline-code-block">88fc37a3c0a4dda553bdcfc80c178a58247f42fb</code>`, false},
|
||||
{"foo `:100:", "foo `:100:", true},
|
||||
{"foo ` :100:", `foo ` + "`" + ` <span class="emoji" aria-label="hundred points">💯</span>`, true},
|
||||
{":100:", `<span class="emoji" aria-label="hundred points">💯</span>`, true},
|
||||
{"#123", `<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>`, false},
|
||||
{"`x`:100:", `<code class="inline-code-block">x</code><span class="emoji" aria-label="hundred points">💯</span>`, true},
|
||||
{"a `:100:` b `:+1:` c", `a <code class="inline-code-block">:100:</code> b <code class="inline-code-block">:+1:</code> c`, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expected, string(ut.RenderIssueTitle(c.input, mockRepo)), "input=%q", c.input)
|
||||
if c.emojiSafe {
|
||||
assert.Equal(t, c.expected, string(ut.RenderIssueSimpleTitle(c.input)), "simple input=%q", c.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToHtml(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
|
||||
@@ -194,6 +242,38 @@ space</p>
|
||||
assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput())))
|
||||
}
|
||||
|
||||
func TestRenderPackageMarkdown(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
mockRepo := &repo.Repository{
|
||||
ID: 1, OwnerName: "user13", Name: "repo11", DefaultBranch: "main",
|
||||
Owner: &user_model.User{ID: 13, Name: "user13"},
|
||||
Units: []*repo.RepoUnit{},
|
||||
}
|
||||
ut := newTestRenderUtils(t)
|
||||
|
||||
t.Run("LinkedRepoWithDirectory", func(t *testing.T) {
|
||||
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)\n", mockRepo, "pkg-subdir")
|
||||
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/pkg-subdir/docs/getting-started.md" rel="nofollow">docs</a>
|
||||
<a href="/user13/repo11/src/branch/main/pkg-subdir/logo.png" target="_blank" rel="nofollow noopener"><img src="/user13/repo11/media/branch/main/pkg-subdir/logo.png" alt="logo"/></a></p>
|
||||
</div>`
|
||||
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
||||
})
|
||||
|
||||
t.Run("LinkedRepoWithEmptyDirectory", func(t *testing.T) {
|
||||
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", mockRepo, "")
|
||||
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/docs/getting-started.md" rel="nofollow">docs</a></p>
|
||||
</div>`
|
||||
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
||||
})
|
||||
|
||||
t.Run("UnlinkedRepo", func(t *testing.T) {
|
||||
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", nil, "pkg-subdir")
|
||||
expected := `<div class="markup markdown"><p><a href="/docs/getting-started.md" rel="nofollow">docs</a></p>
|
||||
</div>`
|
||||
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderLabels(t *testing.T) {
|
||||
ut := newTestRenderUtils(t)
|
||||
label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
|
||||
@@ -206,16 +286,15 @@ func TestRenderLabels(t *testing.T) {
|
||||
expected = `/owner/repo/pulls?labels=123`
|
||||
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
|
||||
|
||||
expectedLabel := `<a href="<>" class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></a>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "<>")))
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, template.URL("<>"))))
|
||||
expectedLabel := `<span class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></span>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
||||
|
||||
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true}
|
||||
expectedLabel = `<a href="" class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">></div></a>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "")))
|
||||
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">></div></span>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
||||
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true, ExclusiveOrder: 1}
|
||||
expectedLabel = `<a href="" class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">></div><div class="ui label scope-right">1</div></a>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabelWithLink(label, "")))
|
||||
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">></div><div class="ui label scope-right">1</div></span>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
||||
}
|
||||
|
||||
func TestUserMention(t *testing.T) {
|
||||
@@ -223,3 +302,52 @@ func TestUserMention(t *testing.T) {
|
||||
rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
|
||||
assert.Equal(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
|
||||
}
|
||||
|
||||
func TestAvatarStack(t *testing.T) {
|
||||
defer test.MockVariableValue(&config.SkipDatabaseConfig, true)()
|
||||
|
||||
ut := newTestRenderUtils(t)
|
||||
mkCo := func(name, email string) *git.CommitIdentity {
|
||||
return &git.CommitIdentity{Name: name, Email: email}
|
||||
}
|
||||
authorSig := mkCo("Alice", "alice@example.com")
|
||||
mkData := func(co ...*git.CommitIdentity) *gituser.AvatarStackData {
|
||||
all := append([]*git.CommitIdentity{authorSig}, co...)
|
||||
return gituser.BuildAvatarStackData(t.Context(), all, &user_model.EmailUserMap{})
|
||||
}
|
||||
|
||||
t.Run("lone author renders bare name, no label", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData()))
|
||||
assert.Contains(t, got, `<span class="avatar-stack-names">`)
|
||||
assert.Contains(t, got, "Alice")
|
||||
assert.NotContains(t, got, "avatar_stack_and")
|
||||
assert.NotContains(t, got, "avatar_stack_people")
|
||||
})
|
||||
|
||||
t.Run("two participants use and label", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"))))
|
||||
assert.Contains(t, got, "repo.commits.avatar_stack_and")
|
||||
assert.Contains(t, got, "Bob")
|
||||
assert.NotContains(t, got, "avatar_stack_people")
|
||||
assert.Contains(t, got, `<span class="avatar-stack">`)
|
||||
})
|
||||
|
||||
t.Run("three participants switch to N people label with tippy popup", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"), mkCo("Carol", "carol@example.com"))))
|
||||
assert.Contains(t, got, "repo.commits.avatar_stack_people:3")
|
||||
assert.NotContains(t, got, "repo.commits.avatar_stack_and")
|
||||
assert.Contains(t, got, `data-global-init="initAvatarStackPopup"`)
|
||||
assert.Contains(t, got, `<div class="tippy-target">`)
|
||||
assert.Contains(t, got, `class="avatar-stack-popup"`)
|
||||
})
|
||||
|
||||
t.Run("overflow chip renders beyond 10 participants", func(t *testing.T) {
|
||||
cos := make([]*git.CommitIdentity, 0, renderAvatarStackMaxVisible+1)
|
||||
for i := range renderAvatarStackMaxVisible + 1 {
|
||||
cos = append(cos, mkCo("X", strconv.Itoa(i)+"@example.com"))
|
||||
}
|
||||
got := ut.AvatarStack(gituser.BuildAvatarStackData(t.Context(), cos, &user_model.EmailUserMap{}))
|
||||
assert.Contains(t, got, `class="avatar-stack-overflow-chip`)
|
||||
assert.Contains(t, got, "+1")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ package templates
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type SliceUtils struct{}
|
||||
@@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// JoinInt64 joins a slice of int64 values into a comma-separated string.
|
||||
func (su *SliceUtils) JoinInt64(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
strs := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strs[i] = strconv.FormatInt(v, 10)
|
||||
}
|
||||
return strings.Join(strs, ",")
|
||||
}
|
||||
|
||||
func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct {
|
||||
IsIncluded bool
|
||||
ToggledIDs string
|
||||
},
|
||||
) {
|
||||
ret.IsIncluded = slices.Contains(values, target)
|
||||
if ret.IsIncluded {
|
||||
ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target))
|
||||
} else {
|
||||
ret.ToggledIDs = su.JoinInt64(append(values, target))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type StringUtils struct{}
|
||||
|
||||
@@ -70,6 +70,16 @@ func TestUtils(t *testing.T) {
|
||||
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
|
||||
assert.Equal(t, "false", actual)
|
||||
|
||||
// Test JoinInt64
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}})
|
||||
assert.Equal(t, "1,2,3", actual)
|
||||
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}})
|
||||
assert.Empty(t, actual)
|
||||
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}})
|
||||
assert.Equal(t, "42", actual)
|
||||
|
||||
tmpl := template.New("test")
|
||||
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
|
||||
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
|
||||
|
||||
Reference in New Issue
Block a user