diff --git a/.deadcode-out b/.deadcode-out
index 9d1d7336e4..3290e6102a 100644
--- a/.deadcode-out
+++ b/.deadcode-out
@@ -289,6 +289,7 @@ package "code.gitea.io/gitea/modules/timeutil"
 
 package "code.gitea.io/gitea/modules/translation"
 	func (MockLocale).Language
+	func (MockLocale).TrString
 	func (MockLocale).Tr
 	func (MockLocale).TrN
 	func (MockLocale).PrettyNumber
diff --git a/models/actions/runner.go b/models/actions/runner.go
index 4103ba4477..b646146ee6 100644
--- a/models/actions/runner.go
+++ b/models/actions/runner.go
@@ -97,7 +97,7 @@ func (r *ActionRunner) StatusName() string {
 }
 
 func (r *ActionRunner) StatusLocaleName(lang translation.Locale) string {
-	return lang.Tr("actions.runners.status." + r.StatusName())
+	return lang.TrString("actions.runners.status." + r.StatusName())
 }
 
 func (r *ActionRunner) IsOnline() bool {
diff --git a/models/actions/status.go b/models/actions/status.go
index c97578f2ac..eda2234137 100644
--- a/models/actions/status.go
+++ b/models/actions/status.go
@@ -41,7 +41,7 @@ func (s Status) String() string {
 
 // LocaleString returns the locale string name of the Status
 func (s Status) LocaleString(lang translation.Locale) string {
-	return lang.Tr("actions.status." + s.String())
+	return lang.TrString("actions.status." + s.String())
 }
 
 // IsDone returns whether the Status is final
diff --git a/models/git/commit_status.go b/models/git/commit_status.go
index 1118b6cc8c..2d1d1bcb06 100644
--- a/models/git/commit_status.go
+++ b/models/git/commit_status.go
@@ -194,7 +194,7 @@ func (status *CommitStatus) APIURL(ctx context.Context) string {
 
 // LocaleString returns the locale string name of the Status
 func (status *CommitStatus) LocaleString(lang translation.Locale) string {
-	return lang.Tr("repo.commitstatus." + status.State.String())
+	return lang.TrString("repo.commitstatus." + status.State.String())
 }
 
 // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc
diff --git a/models/issues/comment.go b/models/issues/comment.go
index 5631b94a50..286a3a2f33 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -210,12 +210,12 @@ const (
 
 // LocaleString returns the locale string name of the role
 func (r RoleInRepo) LocaleString(lang translation.Locale) string {
-	return lang.Tr("repo.issues.role." + string(r))
+	return lang.TrString("repo.issues.role." + string(r))
 }
 
 // LocaleHelper returns the locale tooltip of the role
 func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
-	return lang.Tr("repo.issues.role." + string(r) + "_helper")
+	return lang.TrString("repo.issues.role." + string(r) + "_helper")
 }
 
 // Comment represents a comment in commit and issue page.
diff --git a/models/shared/types/ownertype.go b/models/shared/types/ownertype.go
index e6fe4e4cfd..a1d46c986f 100644
--- a/models/shared/types/ownertype.go
+++ b/models/shared/types/ownertype.go
@@ -17,13 +17,13 @@ const (
 func (o OwnerType) LocaleString(locale translation.Locale) string {
 	switch o {
 	case OwnerTypeSystemGlobal:
-		return locale.Tr("concept_system_global")
+		return locale.TrString("concept_system_global")
 	case OwnerTypeIndividual:
-		return locale.Tr("concept_user_individual")
+		return locale.TrString("concept_user_individual")
 	case OwnerTypeRepository:
-		return locale.Tr("concept_code_repository")
+		return locale.TrString("concept_code_repository")
 	case OwnerTypeOrganization:
-		return locale.Tr("concept_user_organization")
+		return locale.TrString("concept_user_organization")
 	}
-	return locale.Tr("unknown")
+	return locale.TrString("unknown")
 }
diff --git a/modules/auth/password/password.go b/modules/auth/password/password.go
index 2c7205b708..27074358a9 100644
--- a/modules/auth/password/password.go
+++ b/modules/auth/password/password.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"crypto/rand"
 	"errors"
+	"html/template"
 	"math/big"
 	"strings"
 	"sync"
@@ -121,15 +122,15 @@ func Generate(n int) (string, error) {
 }
 
 // BuildComplexityError builds the error message when password complexity checks fail
-func BuildComplexityError(locale translation.Locale) string {
+func BuildComplexityError(locale translation.Locale) template.HTML {
 	var buffer bytes.Buffer
-	buffer.WriteString(locale.Tr("form.password_complexity"))
+	buffer.WriteString(locale.TrString("form.password_complexity"))
 	buffer.WriteString("<ul>")
 	for _, c := range requiredList {
 		buffer.WriteString("<li>")
-		buffer.WriteString(locale.Tr(c.TrNameOne))
+		buffer.WriteString(locale.TrString(c.TrNameOne))
 		buffer.WriteString("</li>")
 	}
 	buffer.WriteString("</ul>")
-	return buffer.String()
+	return template.HTML(buffer.String())
 }
diff --git a/modules/charset/escape_stream.go b/modules/charset/escape_stream.go
index 3f08fd94a4..29943eb858 100644
--- a/modules/charset/escape_stream.go
+++ b/modules/charset/escape_stream.go
@@ -173,7 +173,7 @@ func (e *escapeStreamer) ambiguousRune(r, c rune) error {
 		Val: "ambiguous-code-point",
 	}, html.Attribute{
 		Key: "data-tooltip-content",
-		Val: e.locale.Tr("repo.ambiguous_character", r, c),
+		Val: e.locale.TrString("repo.ambiguous_character", r, c),
 	}); err != nil {
 		return err
 	}
diff --git a/modules/context/api.go b/modules/context/api.go
index 05b6a7a533..7557a5f435 100644
--- a/modules/context/api.go
+++ b/modules/context/api.go
@@ -247,7 +247,7 @@ func APIContexter() func(http.Handler) http.Handler {
 // NotFound handles 404s for APIContext
 // String will replace message, errors will be added to a slice
 func (ctx *APIContext) NotFound(objs ...any) {
-	message := ctx.Tr("error.not_found")
+	message := ctx.Locale.TrString("error.not_found")
 	var errors []string
 	for _, obj := range objs {
 		// Ignore nil
diff --git a/modules/context/base.go b/modules/context/base.go
index 8df1dde866..fa05850a16 100644
--- a/modules/context/base.go
+++ b/modules/context/base.go
@@ -6,6 +6,7 @@ package context
 import (
 	"context"
 	"fmt"
+	"html/template"
 	"io"
 	"net/http"
 	"net/url"
@@ -286,11 +287,11 @@ func (b *Base) cleanUp() {
 	}
 }
 
-func (b *Base) Tr(msg string, args ...any) string {
+func (b *Base) Tr(msg string, args ...any) template.HTML {
 	return b.Locale.Tr(msg, args...)
 }
 
-func (b *Base) TrN(cnt any, key1, keyN string, args ...any) string {
+func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
 	return b.Locale.TrN(cnt, key1, keyN, args...)
 }
 
diff --git a/modules/context/context.go b/modules/context/context.go
index d19c5d1198..4d367b3242 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -6,7 +6,7 @@ package context
 
 import (
 	"context"
-	"html"
+	"fmt"
 	"html/template"
 	"io"
 	"net/http"
@@ -71,16 +71,6 @@ func init() {
 	})
 }
 
-// TrHTMLEscapeArgs runs ".Locale.Tr()" but pre-escapes all arguments with html.EscapeString.
-// This is useful if the locale message is intended to only produce HTML content.
-func (ctx *Context) TrHTMLEscapeArgs(msg string, args ...string) string {
-	trArgs := make([]any, len(args))
-	for i, arg := range args {
-		trArgs[i] = html.EscapeString(arg)
-	}
-	return ctx.Locale.Tr(msg, trArgs...)
-}
-
 type webContextKeyType struct{}
 
 var WebContextKey = webContextKeyType{}
@@ -253,6 +243,13 @@ func (ctx *Context) JSONOK() {
 	ctx.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it
 }
 
-func (ctx *Context) JSONError(msg string) {
-	ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg})
+func (ctx *Context) JSONError(msg any) {
+	switch v := msg.(type) {
+	case string:
+		ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "text"})
+	case template.HTML:
+		ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "html"})
+	default:
+		panic(fmt.Sprintf("unsupported type: %T", msg))
+	}
 }
diff --git a/modules/context/context_response.go b/modules/context/context_response.go
index 5729865561..d9102b77bd 100644
--- a/modules/context/context_response.go
+++ b/modules/context/context_response.go
@@ -98,12 +98,11 @@ func (ctx *Context) RenderToString(name base.TplName, data map[string]any) (stri
 }
 
 // RenderWithErr used for page has form validation but need to prompt error to users.
-func (ctx *Context) RenderWithErr(msg string, tpl base.TplName, form any) {
+func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) {
 	if form != nil {
 		middleware.AssignForm(form, ctx.Data)
 	}
-	ctx.Flash.ErrorMsg = msg
-	ctx.Data["Flash"] = ctx.Flash
+	ctx.Flash.Error(msg, true)
 	ctx.HTML(http.StatusOK, tpl)
 }
 
diff --git a/modules/context/repo.go b/modules/context/repo.go
index 727c18cad6..8e8a42b695 100644
--- a/modules/context/repo.go
+++ b/modules/context/repo.go
@@ -6,6 +6,7 @@ package context
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"html"
 	"net/http"
@@ -110,7 +111,7 @@ func (r *Repository) AllUnitsEnabled(ctx context.Context) bool {
 func RepoMustNotBeArchived() func(ctx *Context) {
 	return func(ctx *Context) {
 		if ctx.Repo.Repository.IsArchived {
-			ctx.NotFound("IsArchived", fmt.Errorf(ctx.Tr("repo.archive.title")))
+			ctx.NotFound("IsArchived", errors.New(ctx.Locale.TrString("repo.archive.title")))
 		}
 	}
 }
diff --git a/modules/csv/csv.go b/modules/csv/csv.go
index c5497befe7..35c5d6ab67 100644
--- a/modules/csv/csv.go
+++ b/modules/csv/csv.go
@@ -123,9 +123,9 @@ func guessDelimiter(data []byte) rune {
 func FormatError(err error, locale translation.Locale) (string, error) {
 	if perr, ok := err.(*stdcsv.ParseError); ok {
 		if perr.Err == stdcsv.ErrFieldCount {
-			return locale.Tr("repo.error.csv.invalid_field_count", perr.Line), nil
+			return locale.TrString("repo.error.csv.invalid_field_count", perr.Line), nil
 		}
-		return locale.Tr("repo.error.csv.unexpected", perr.Line, perr.Column), nil
+		return locale.TrString("repo.error.csv.unexpected", perr.Line, perr.Column), nil
 	}
 
 	return "", err
diff --git a/modules/markup/html.go b/modules/markup/html.go
index 33dc1e9086..b7291823b5 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -804,7 +804,7 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
 		// indicate that in the text by appending (comment)
 		if m[4] != -1 && m[5] != -1 {
 			if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
-				text += " " + locale.Tr("repo.from_comment")
+				text += " " + locale.TrString("repo.from_comment")
 			} else {
 				text += " (comment)"
 			}
diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go
index 9602040931..38f744a25f 100644
--- a/modules/markup/markdown/toc.go
+++ b/modules/markup/markdown/toc.go
@@ -21,7 +21,7 @@ func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]str
 		details.SetAttributeString(k, []byte(v))
 	}
 
-	summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).Tr("toc"))))
+	summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc"))))
 	details.AppendChild(details, summary)
 	ul := ast.NewList('-')
 	details.AppendChild(details, ul)
diff --git a/modules/migration/messenger.go b/modules/migration/messenger.go
index 924aac9769..6f9cad3f10 100644
--- a/modules/migration/messenger.go
+++ b/modules/migration/messenger.go
@@ -3,7 +3,7 @@
 
 package migration
 
-// Messenger is a formatting function similar to i18n.Tr
+// Messenger is a formatting function similar to i18n.TrString
 type Messenger func(key string, args ...any)
 
 // NilMessenger represents an empty formatting function
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index bcb94bff25..90371fa8fe 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -36,7 +36,7 @@ func NewFuncMap() template.FuncMap {
 		"dict":        dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
 		"Eval":        Eval,
 		"Safe":        Safe,
-		"Escape":      html.EscapeString,
+		"Escape":      Escape,
 		"QueryEscape": url.QueryEscape,
 		"JSEscape":    template.JSEscapeString,
 		"Str2html":    Str2html, // TODO: rename it to SanitizeHTML
@@ -162,7 +162,7 @@ func NewFuncMap() template.FuncMap {
 		"RenderCodeBlock":  RenderCodeBlock,
 		"RenderIssueTitle": RenderIssueTitle,
 		"RenderEmoji":      RenderEmoji,
-		"RenderEmojiPlain": emoji.ReplaceAliases,
+		"RenderEmojiPlain": RenderEmojiPlain,
 		"ReactionToEmoji":  ReactionToEmoji,
 
 		"RenderMarkdownToHtml": RenderMarkdownToHtml,
@@ -183,13 +183,45 @@ func NewFuncMap() template.FuncMap {
 }
 
 // Safe render raw as HTML
-func Safe(raw string) template.HTML {
-	return template.HTML(raw)
+func Safe(s any) template.HTML {
+	switch v := s.(type) {
+	case string:
+		return template.HTML(v)
+	case template.HTML:
+		return v
+	}
+	panic(fmt.Sprintf("unexpected type %T", s))
 }
 
-// Str2html render Markdown text to HTML
-func Str2html(raw string) template.HTML {
-	return template.HTML(markup.Sanitize(raw))
+// Str2html sanitizes the input by pre-defined markdown rules
+func Str2html(s any) template.HTML {
+	switch v := s.(type) {
+	case string:
+		return template.HTML(markup.Sanitize(v))
+	case template.HTML:
+		return template.HTML(markup.Sanitize(string(v)))
+	}
+	panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+func Escape(s any) template.HTML {
+	switch v := s.(type) {
+	case string:
+		return template.HTML(html.EscapeString(v))
+	case template.HTML:
+		return v
+	}
+	panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+func RenderEmojiPlain(s any) any {
+	switch v := s.(type) {
+	case string:
+		return emoji.ReplaceAliases(v)
+	case template.HTML:
+		return template.HTML(emoji.ReplaceAliases(string(v)))
+	}
+	panic(fmt.Sprintf("unexpected type %T", s))
 }
 
 // DotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent autolinkers from detecting these as urls
diff --git a/modules/timeutil/since.go b/modules/timeutil/since.go
index 1cb3c4f288..dfaa0e3e3a 100644
--- a/modules/timeutil/since.go
+++ b/modules/timeutil/since.go
@@ -28,54 +28,54 @@ func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) {
 	switch {
 	case diff <= 0:
 		diff = 0
-		diffStr = lang.Tr("tool.now")
+		diffStr = lang.TrString("tool.now")
 	case diff < 2:
 		diff = 0
-		diffStr = lang.Tr("tool.1s")
+		diffStr = lang.TrString("tool.1s")
 	case diff < 1*Minute:
-		diffStr = lang.Tr("tool.seconds", diff)
+		diffStr = lang.TrString("tool.seconds", diff)
 		diff = 0
 
 	case diff < 2*Minute:
 		diff -= 1 * Minute
-		diffStr = lang.Tr("tool.1m")
+		diffStr = lang.TrString("tool.1m")
 	case diff < 1*Hour:
-		diffStr = lang.Tr("tool.minutes", diff/Minute)
+		diffStr = lang.TrString("tool.minutes", diff/Minute)
 		diff -= diff / Minute * Minute
 
 	case diff < 2*Hour:
 		diff -= 1 * Hour
-		diffStr = lang.Tr("tool.1h")
+		diffStr = lang.TrString("tool.1h")
 	case diff < 1*Day:
-		diffStr = lang.Tr("tool.hours", diff/Hour)
+		diffStr = lang.TrString("tool.hours", diff/Hour)
 		diff -= diff / Hour * Hour
 
 	case diff < 2*Day:
 		diff -= 1 * Day
-		diffStr = lang.Tr("tool.1d")
+		diffStr = lang.TrString("tool.1d")
 	case diff < 1*Week:
-		diffStr = lang.Tr("tool.days", diff/Day)
+		diffStr = lang.TrString("tool.days", diff/Day)
 		diff -= diff / Day * Day
 
 	case diff < 2*Week:
 		diff -= 1 * Week
-		diffStr = lang.Tr("tool.1w")
+		diffStr = lang.TrString("tool.1w")
 	case diff < 1*Month:
-		diffStr = lang.Tr("tool.weeks", diff/Week)
+		diffStr = lang.TrString("tool.weeks", diff/Week)
 		diff -= diff / Week * Week
 
 	case diff < 2*Month:
 		diff -= 1 * Month
-		diffStr = lang.Tr("tool.1mon")
+		diffStr = lang.TrString("tool.1mon")
 	case diff < 1*Year:
-		diffStr = lang.Tr("tool.months", diff/Month)
+		diffStr = lang.TrString("tool.months", diff/Month)
 		diff -= diff / Month * Month
 
 	case diff < 2*Year:
 		diff -= 1 * Year
-		diffStr = lang.Tr("tool.1y")
+		diffStr = lang.TrString("tool.1y")
 	default:
-		diffStr = lang.Tr("tool.years", diff/Year)
+		diffStr = lang.TrString("tool.years", diff/Year)
 		diff -= (diff / Year) * Year
 	}
 	return diff, diffStr
@@ -97,10 +97,10 @@ func timeSincePro(then, now time.Time, lang translation.Locale) string {
 	diff := now.Unix() - then.Unix()
 
 	if then.After(now) {
-		return lang.Tr("tool.future")
+		return lang.TrString("tool.future")
 	}
 	if diff == 0 {
-		return lang.Tr("tool.now")
+		return lang.TrString("tool.now")
 	}
 
 	var timeStr, diffStr string
@@ -115,7 +115,7 @@ func timeSincePro(then, now time.Time, lang translation.Locale) string {
 	return strings.TrimPrefix(timeStr, ", ")
 }
 
-func timeSinceUnix(then, now time.Time, lang translation.Locale) template.HTML {
+func timeSinceUnix(then, now time.Time, _ translation.Locale) template.HTML {
 	friendlyText := then.Format("2006-01-02 15:04:05 -07:00")
 
 	// document: https://github.com/github/relative-time-element
diff --git a/modules/translation/i18n/i18n.go b/modules/translation/i18n/i18n.go
index 42475545b3..1555cd961e 100644
--- a/modules/translation/i18n/i18n.go
+++ b/modules/translation/i18n/i18n.go
@@ -4,26 +4,25 @@
 package i18n
 
 import (
+	"html/template"
 	"io"
 )
 
 var DefaultLocales = NewLocaleStore()
 
 type Locale interface {
-	// Tr translates a given key and arguments for a language
-	Tr(trKey string, trArgs ...any) string
-	// Has reports if a locale has a translation for a given key
-	Has(trKey string) bool
+	// TrString translates a given key and arguments for a language
+	TrString(trKey string, trArgs ...any) string
+	// TrHTML translates a given key and arguments for a language, string arguments are escaped to HTML
+	TrHTML(trKey string, trArgs ...any) template.HTML
+	// HasKey reports if a locale has a translation for a given key
+	HasKey(trKey string) bool
 }
 
 // LocaleStore provides the functions common to all locale stores
 type LocaleStore interface {
 	io.Closer
 
-	// Tr translates a given key and arguments for a language
-	Tr(lang, trKey string, trArgs ...any) string
-	// Has reports if a locale has a translation for a given key
-	Has(lang, trKey string) bool
 	// SetDefaultLang sets the default language to fall back to
 	SetDefaultLang(lang string)
 	// ListLangNameDesc provides paired slices of language names to descriptors
@@ -45,7 +44,7 @@ func ResetDefaultLocales() {
 	DefaultLocales = NewLocaleStore()
 }
 
-// GetLocales returns the locale from the default locales
+// GetLocale returns the locale from the default locales
 func GetLocale(lang string) (Locale, bool) {
 	return DefaultLocales.Locale(lang)
 }
diff --git a/modules/translation/i18n/i18n_test.go b/modules/translation/i18n/i18n_test.go
index 1d1be43318..ffe69a74df 100644
--- a/modules/translation/i18n/i18n_test.go
+++ b/modules/translation/i18n/i18n_test.go
@@ -17,7 +17,7 @@ fmt = %[1]s %[2]s
 
 [section]
 sub = Sub String
-mixed = test value; <span style="color: red\; background: none;">more text</span>
+mixed = test value; <span style="color: red\; background: none;">%s</span>
 `)
 
 	testData2 := []byte(`
@@ -32,29 +32,33 @@ sub = Changed Sub String
 	assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2, nil))
 	ls.SetDefaultLang("lang1")
 
-	result := ls.Tr("lang1", "fmt", "a", "b")
+	lang1, _ := ls.Locale("lang1")
+	lang2, _ := ls.Locale("lang2")
+
+	result := lang1.TrString("fmt", "a", "b")
 	assert.Equal(t, "a b", result)
 
-	result = ls.Tr("lang2", "fmt", "a", "b")
+	result = lang2.TrString("fmt", "a", "b")
 	assert.Equal(t, "b a", result)
 
-	result = ls.Tr("lang1", "section.sub")
+	result = lang1.TrString("section.sub")
 	assert.Equal(t, "Sub String", result)
 
-	result = ls.Tr("lang2", "section.sub")
+	result = lang2.TrString("section.sub")
 	assert.Equal(t, "Changed Sub String", result)
 
-	result = ls.Tr("", ".dot.name")
+	langNone, _ := ls.Locale("none")
+	result = langNone.TrString(".dot.name")
 	assert.Equal(t, "Dot Name", result)
 
-	result = ls.Tr("lang2", "section.mixed")
-	assert.Equal(t, `test value; <span style="color: red; background: none;">more text</span>`, result)
+	result2 := lang2.TrHTML("section.mixed", "a&b")
+	assert.EqualValues(t, `test value; <span style="color: red; background: none;">a&amp;b</span>`, result2)
 
 	langs, descs := ls.ListLangNameDesc()
 	assert.ElementsMatch(t, []string{"lang1", "lang2"}, langs)
 	assert.ElementsMatch(t, []string{"Lang1", "Lang2"}, descs)
 
-	found := ls.Has("lang1", "no-such")
+	found := lang1.HasKey("no-such")
 	assert.False(t, found)
 	assert.NoError(t, ls.Close())
 }
@@ -72,9 +76,10 @@ c=22
 
 	ls := NewLocaleStore()
 	assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1, testData2))
-	assert.Equal(t, "11", ls.Tr("lang1", "a"))
-	assert.Equal(t, "21", ls.Tr("lang1", "b"))
-	assert.Equal(t, "22", ls.Tr("lang1", "c"))
+	lang1, _ := ls.Locale("lang1")
+	assert.Equal(t, "11", lang1.TrString("a"))
+	assert.Equal(t, "21", lang1.TrString("b"))
+	assert.Equal(t, "22", lang1.TrString("c"))
 }
 
 func TestLocaleStoreQuirks(t *testing.T) {
@@ -110,8 +115,9 @@ func TestLocaleStoreQuirks(t *testing.T) {
 	for _, testData := range testDataList {
 		ls := NewLocaleStore()
 		err := ls.AddLocaleByIni("lang1", "Lang1", []byte("a="+testData.in), nil)
+		lang1, _ := ls.Locale("lang1")
 		assert.NoError(t, err, testData.hint)
-		assert.Equal(t, testData.out, ls.Tr("lang1", "a"), testData.hint)
+		assert.Equal(t, testData.out, lang1.TrString("a"), testData.hint)
 		assert.NoError(t, ls.Close())
 	}
 
diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go
index 42b95dda54..d0b5e05649 100644
--- a/modules/translation/i18n/localestore.go
+++ b/modules/translation/i18n/localestore.go
@@ -5,6 +5,8 @@ package i18n
 
 import (
 	"fmt"
+	"html/template"
+	"slices"
 
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -18,6 +20,8 @@ type locale struct {
 	idxToMsgMap map[int]string // the map idx is generated by store's trKeyToIdxMap
 }
 
+var _ Locale = (*locale)(nil)
+
 type localeStore struct {
 	// After initializing has finished, these fields are read-only.
 	langNames []string
@@ -88,20 +92,6 @@ func (store *localeStore) SetDefaultLang(lang string) {
 	store.defaultLang = lang
 }
 
-// Tr translates content to target language. fall back to default language.
-func (store *localeStore) Tr(lang, trKey string, trArgs ...any) string {
-	l, _ := store.Locale(lang)
-
-	return l.Tr(trKey, trArgs...)
-}
-
-// Has returns whether the given language has a translation for the provided key
-func (store *localeStore) Has(lang, trKey string) bool {
-	l, _ := store.Locale(lang)
-
-	return l.Has(trKey)
-}
-
 // Locale returns the locale for the lang or the default language
 func (store *localeStore) Locale(lang string) (Locale, bool) {
 	l, found := store.localeMap[lang]
@@ -116,13 +106,11 @@ func (store *localeStore) Locale(lang string) (Locale, bool) {
 	return l, found
 }
 
-// Close implements io.Closer
 func (store *localeStore) Close() error {
 	return nil
 }
 
-// Tr translates content to locale language. fall back to default language.
-func (l *locale) Tr(trKey string, trArgs ...any) string {
+func (l *locale) TrString(trKey string, trArgs ...any) string {
 	format := trKey
 
 	idx, ok := l.store.trKeyToIdxMap[trKey]
@@ -144,8 +132,23 @@ func (l *locale) Tr(trKey string, trArgs ...any) string {
 	return msg
 }
 
-// Has returns whether a key is present in this locale or not
-func (l *locale) Has(trKey string) bool {
+func (l *locale) TrHTML(trKey string, trArgs ...any) template.HTML {
+	args := slices.Clone(trArgs)
+	for i, v := range args {
+		switch v := v.(type) {
+		case string:
+			args[i] = template.HTML(template.HTMLEscapeString(v))
+		case fmt.Stringer:
+			args[i] = template.HTMLEscapeString(v.String())
+		default: // int, float, include template.HTML
+			// do nothing, just use it
+		}
+	}
+	return template.HTML(l.TrString(trKey, args...))
+}
+
+// HasKey returns whether a key is present in this locale or not
+func (l *locale) HasKey(trKey string) bool {
 	idx, ok := l.store.trKeyToIdxMap[trKey]
 	if !ok {
 		return false
diff --git a/modules/translation/mock.go b/modules/translation/mock.go
index 2d0cb17324..1f0559f38d 100644
--- a/modules/translation/mock.go
+++ b/modules/translation/mock.go
@@ -3,7 +3,10 @@
 
 package translation
 
-import "fmt"
+import (
+	"fmt"
+	"html/template"
+)
 
 // MockLocale provides a mocked locale without any translations
 type MockLocale struct{}
@@ -14,12 +17,16 @@ func (l MockLocale) Language() string {
 	return "en"
 }
 
-func (l MockLocale) Tr(s string, _ ...any) string {
+func (l MockLocale) TrString(s string, _ ...any) string {
 	return s
 }
 
-func (l MockLocale) TrN(_cnt any, key1, _keyN string, _args ...any) string {
-	return key1
+func (l MockLocale) Tr(s string, a ...any) template.HTML {
+	return template.HTML(s)
+}
+
+func (l MockLocale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
+	return template.HTML(key1)
 }
 
 func (l MockLocale) PrettyNumber(v any) string {
diff --git a/modules/translation/translation.go b/modules/translation/translation.go
index dba4de6607..b7c18f610a 100644
--- a/modules/translation/translation.go
+++ b/modules/translation/translation.go
@@ -5,6 +5,7 @@ package translation
 
 import (
 	"context"
+	"html/template"
 	"sort"
 	"strings"
 	"sync"
@@ -27,8 +28,11 @@ var ContextKey any = &contextKey{}
 // Locale represents an interface to translation
 type Locale interface {
 	Language() string
-	Tr(string, ...any) string
-	TrN(cnt any, key1, keyN string, args ...any) string
+	TrString(string, ...any) string
+
+	Tr(key string, args ...any) template.HTML
+	TrN(cnt any, key1, keyN string, args ...any) template.HTML
+
 	PrettyNumber(v any) string
 }
 
@@ -144,6 +148,8 @@ type locale struct {
 	msgPrinter     *message.Printer
 }
 
+var _ Locale = (*locale)(nil)
+
 // NewLocale return a locale
 func NewLocale(lang string) Locale {
 	if lock != nil {
@@ -216,8 +222,12 @@ var trNLangRules = map[string]func(int64) int{
 	},
 }
 
+func (l *locale) Tr(s string, args ...any) template.HTML {
+	return l.TrHTML(s, args...)
+}
+
 // TrN returns translated message for plural text translation
-func (l *locale) TrN(cnt any, key1, keyN string, args ...any) string {
+func (l *locale) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
 	var c int64
 	if t, ok := cnt.(int); ok {
 		c = int64(t)
diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go
index 4e7fca80e2..4891e43f27 100644
--- a/modules/web/middleware/binding.go
+++ b/modules/web/middleware/binding.go
@@ -105,44 +105,44 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
 
 			trName := field.Tag.Get("locale")
 			if len(trName) == 0 {
-				trName = l.Tr("form." + field.Name)
+				trName = l.TrString("form." + field.Name)
 			} else {
-				trName = l.Tr(trName)
+				trName = l.TrString(trName)
 			}
 
 			switch errs[0].Classification {
 			case binding.ERR_REQUIRED:
-				data["ErrorMsg"] = trName + l.Tr("form.require_error")
+				data["ErrorMsg"] = trName + l.TrString("form.require_error")
 			case binding.ERR_ALPHA_DASH:
-				data["ErrorMsg"] = trName + l.Tr("form.alpha_dash_error")
+				data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_error")
 			case binding.ERR_ALPHA_DASH_DOT:
-				data["ErrorMsg"] = trName + l.Tr("form.alpha_dash_dot_error")
+				data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_dot_error")
 			case validation.ErrGitRefName:
-				data["ErrorMsg"] = trName + l.Tr("form.git_ref_name_error")
+				data["ErrorMsg"] = trName + l.TrString("form.git_ref_name_error")
 			case binding.ERR_SIZE:
-				data["ErrorMsg"] = trName + l.Tr("form.size_error", GetSize(field))
+				data["ErrorMsg"] = trName + l.TrString("form.size_error", GetSize(field))
 			case binding.ERR_MIN_SIZE:
-				data["ErrorMsg"] = trName + l.Tr("form.min_size_error", GetMinSize(field))
+				data["ErrorMsg"] = trName + l.TrString("form.min_size_error", GetMinSize(field))
 			case binding.ERR_MAX_SIZE:
-				data["ErrorMsg"] = trName + l.Tr("form.max_size_error", GetMaxSize(field))
+				data["ErrorMsg"] = trName + l.TrString("form.max_size_error", GetMaxSize(field))
 			case binding.ERR_EMAIL:
-				data["ErrorMsg"] = trName + l.Tr("form.email_error")
+				data["ErrorMsg"] = trName + l.TrString("form.email_error")
 			case binding.ERR_URL:
-				data["ErrorMsg"] = trName + l.Tr("form.url_error", errs[0].Message)
+				data["ErrorMsg"] = trName + l.TrString("form.url_error", errs[0].Message)
 			case binding.ERR_INCLUDE:
-				data["ErrorMsg"] = trName + l.Tr("form.include_error", GetInclude(field))
+				data["ErrorMsg"] = trName + l.TrString("form.include_error", GetInclude(field))
 			case validation.ErrGlobPattern:
-				data["ErrorMsg"] = trName + l.Tr("form.glob_pattern_error", errs[0].Message)
+				data["ErrorMsg"] = trName + l.TrString("form.glob_pattern_error", errs[0].Message)
 			case validation.ErrRegexPattern:
-				data["ErrorMsg"] = trName + l.Tr("form.regex_pattern_error", errs[0].Message)
+				data["ErrorMsg"] = trName + l.TrString("form.regex_pattern_error", errs[0].Message)
 			case validation.ErrUsername:
 				if setting.Service.AllowDotsInUsernames {
-					data["ErrorMsg"] = trName + l.Tr("form.username_error")
+					data["ErrorMsg"] = trName + l.TrString("form.username_error")
 				} else {
-					data["ErrorMsg"] = trName + l.Tr("form.username_error_no_dots")
+					data["ErrorMsg"] = trName + l.TrString("form.username_error_no_dots")
 				}
 			case validation.ErrInvalidGroupTeamMap:
-				data["ErrorMsg"] = trName + l.Tr("form.invalid_group_team_map_error", errs[0].Message)
+				data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message)
 			default:
 				msg := errs[0].Classification
 				if msg != "" && errs[0].Message != "" {
@@ -151,7 +151,7 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
 
 				msg += errs[0].Message
 				if msg == "" {
-					msg = l.Tr("form.unknown_error")
+					msg = l.TrString("form.unknown_error")
 				}
 				data["ErrorMsg"] = trName + ": " + msg
 			}
diff --git a/modules/web/middleware/flash.go b/modules/web/middleware/flash.go
index 41f3aac27c..88da2049a4 100644
--- a/modules/web/middleware/flash.go
+++ b/modules/web/middleware/flash.go
@@ -3,7 +3,11 @@
 
 package middleware
 
-import "net/url"
+import (
+	"fmt"
+	"html/template"
+	"net/url"
+)
 
 // Flash represents a one time data transfer between two requests.
 type Flash struct {
@@ -26,26 +30,36 @@ func (f *Flash) set(name, msg string, current ...bool) {
 	}
 }
 
+func flashMsgStringOrHTML(msg any) string {
+	switch v := msg.(type) {
+	case string:
+		return v
+	case template.HTML:
+		return string(v)
+	}
+	panic(fmt.Sprintf("unknown type: %T", msg))
+}
+
 // Error sets error message
-func (f *Flash) Error(msg string, current ...bool) {
-	f.ErrorMsg = msg
-	f.set("error", msg, current...)
+func (f *Flash) Error(msg any, current ...bool) {
+	f.ErrorMsg = flashMsgStringOrHTML(msg)
+	f.set("error", f.ErrorMsg, current...)
 }
 
 // Warning sets warning message
-func (f *Flash) Warning(msg string, current ...bool) {
-	f.WarningMsg = msg
-	f.set("warning", msg, current...)
+func (f *Flash) Warning(msg any, current ...bool) {
+	f.WarningMsg = flashMsgStringOrHTML(msg)
+	f.set("warning", f.WarningMsg, current...)
 }
 
 // Info sets info message
-func (f *Flash) Info(msg string, current ...bool) {
-	f.InfoMsg = msg
-	f.set("info", msg, current...)
+func (f *Flash) Info(msg any, current ...bool) {
+	f.InfoMsg = flashMsgStringOrHTML(msg)
+	f.set("info", f.InfoMsg, current...)
 }
 
 // Success sets success message
-func (f *Flash) Success(msg string, current ...bool) {
-	f.SuccessMsg = msg
-	f.set("success", msg, current...)
+func (f *Flash) Success(msg any, current ...bool) {
+	f.SuccessMsg = flashMsgStringOrHTML(msg)
+	f.set("success", f.SuccessMsg, current...)
 }
diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go
index 7988dec8d3..49d85bf914 100644
--- a/routers/api/v1/repo/file.go
+++ b/routers/api/v1/repo/file.go
@@ -779,13 +779,13 @@ func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.Ch
 	}
 	message := ""
 	if len(createFiles) != 0 {
-		message += ctx.Tr("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
+		message += ctx.Locale.TrString("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
 	}
 	if len(updateFiles) != 0 {
-		message += ctx.Tr("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
+		message += ctx.Locale.TrString("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
 	}
 	if len(deleteFiles) != 0 {
-		message += ctx.Tr("repo.editor.delete", strings.Join(deleteFiles, ", "))
+		message += ctx.Locale.TrString("repo.editor.delete", strings.Join(deleteFiles, ", "))
 	}
 	return strings.Trim(message, "\n")
 }
diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go
index 6f70e6bcec..35d7658b6c 100644
--- a/routers/api/v1/repo/issue_comment.go
+++ b/routers/api/v1/repo/issue_comment.go
@@ -395,7 +395,7 @@ func CreateIssueComment(ctx *context.APIContext) {
 	}
 
 	if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
-		ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Tr("repo.issues.comment_on_locked")))
+		ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Locale.TrString("repo.issues.comment_on_locked")))
 		return
 	}
 
diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go
index 2cf63c646d..7fdd18dfae 100644
--- a/routers/web/admin/auths.go
+++ b/routers/web/admin/auths.go
@@ -210,16 +210,16 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
 func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) {
 	if util.IsEmptyString(form.SSPISeparatorReplacement) {
 		ctx.Data["Err_SSPISeparatorReplacement"] = true
-		return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.require_error"))
+		return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.require_error"))
 	}
 	if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) {
 		ctx.Data["Err_SSPISeparatorReplacement"] = true
-		return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.alpha_dash_dot_error"))
+		return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.alpha_dash_dot_error"))
 	}
 
 	if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) {
 		ctx.Data["Err_SSPIDefaultLanguage"] = true
-		return nil, errors.New(ctx.Tr("form.lang_select_error"))
+		return nil, errors.New(ctx.Locale.TrString("form.lang_select_error"))
 	}
 
 	return &sspi.Source{
diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go
index 5af1696a64..c23379b87a 100644
--- a/routers/web/auth/password.go
+++ b/routers/web/auth/password.go
@@ -37,7 +37,7 @@ func ForgotPasswd(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
 
 	if setting.MailService == nil {
-		log.Warn(ctx.Tr("auth.disable_forgot_password_mail_admin"))
+		log.Warn("no mail service configured")
 		ctx.Data["IsResetDisable"] = true
 		ctx.HTML(http.StatusOK, tplForgotPassword)
 		return
diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go
index 3cb3e62245..5186d1a524 100644
--- a/routers/web/feed/convert.go
+++ b/routers/web/feed/convert.go
@@ -6,6 +6,7 @@ package feed
 import (
 	"fmt"
 	"html"
+	"html/template"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -80,119 +81,120 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
 
 		// title
 		title = act.ActUser.DisplayName() + " "
+		var titleExtra template.HTML
 		switch act.OpType {
 		case activities_model.ActionCreateRepo:
-			title += ctx.TrHTMLEscapeArgs("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
 			link.Href = act.GetRepoAbsoluteLink(ctx)
 		case activities_model.ActionRenameRepo:
-			title += ctx.TrHTMLEscapeArgs("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
 			link.Href = act.GetRepoAbsoluteLink(ctx)
 		case activities_model.ActionCommitRepo:
 			link.Href = toBranchLink(ctx, act)
 			if len(act.Content) != 0 {
-				title += ctx.TrHTMLEscapeArgs("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
+				titleExtra = ctx.Locale.Tr("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
 			} else {
-				title += ctx.TrHTMLEscapeArgs("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
+				titleExtra = ctx.Locale.Tr("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
 			}
 		case activities_model.ActionCreateIssue:
 			link.Href = toIssueLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionCreatePullRequest:
 			link.Href = toPullLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionTransferRepo:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
 		case activities_model.ActionPushTag:
 			link.Href = toTagLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx))
 		case activities_model.ActionCommentIssue:
 			issueLink := toIssueLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = issueLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionMergePullRequest:
 			pullLink := toPullLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = pullLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionAutoMergePullRequest:
 			pullLink := toPullLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = pullLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionCloseIssue:
 			issueLink := toIssueLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = issueLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionReopenIssue:
 			issueLink := toIssueLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = issueLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionClosePullRequest:
 			pullLink := toPullLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = pullLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionReopenPullRequest:
 			pullLink := toPullLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = pullLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionDeleteTag:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx))
 		case activities_model.ActionDeleteBranch:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx))
 		case activities_model.ActionMirrorSyncPush:
 			srcLink := toSrcLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = srcLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx))
 		case activities_model.ActionMirrorSyncCreate:
 			srcLink := toSrcLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = srcLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx))
 		case activities_model.ActionMirrorSyncDelete:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.GetBranch(), act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.GetBranch(), act.ShortRepoPath(ctx))
 		case activities_model.ActionApprovePullRequest:
 			pullLink := toPullLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionRejectPullRequest:
 			pullLink := toPullLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionCommentPull:
 			pullLink := toPullLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
 		case activities_model.ActionPublishRelease:
 			releaseLink := toReleaseLink(ctx, act)
 			if link.Href == "#" {
 				link.Href = releaseLink
 			}
-			title += ctx.TrHTMLEscapeArgs("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content)
+			titleExtra = ctx.Locale.Tr("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content)
 		case activities_model.ActionPullReviewDismissed:
 			pullLink := toPullLink(ctx, act)
-			title += ctx.TrHTMLEscapeArgs("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1])
+			titleExtra = ctx.Locale.Tr("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1])
 		case activities_model.ActionStarRepo:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
 		case activities_model.ActionWatchRepo:
 			link.Href = act.GetRepoAbsoluteLink(ctx)
-			title += ctx.TrHTMLEscapeArgs("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
+			titleExtra = ctx.Locale.Tr("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
 		default:
 			return nil, fmt.Errorf("unknown action type: %v", act.OpType)
 		}
@@ -234,7 +236,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
 			case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest:
 				desc = act.GetIssueTitle(ctx)
 			case activities_model.ActionPullReviewDismissed:
-				desc = ctx.Tr("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2]
+				desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2]
 			}
 		}
 		if len(content) == 0 {
@@ -243,7 +245,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
 
 		// It's a common practice for feed generators to use plain text titles.
 		// See https://codeberg.org/forgejo/forgejo/pulls/1595
-		plainTitle, err := html2text.FromString(title, html2text.Options{OmitLinks: true})
+		plainTitle, err := html2text.FromString(title+" "+string(titleExtra), html2text.Options{OmitLinks: true})
 		if err != nil {
 			return nil, err
 		}
diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go
index 04f84c0c8d..3feca68d61 100644
--- a/routers/web/feed/profile.go
+++ b/routers/web/feed/profile.go
@@ -56,7 +56,7 @@ func showUserFeed(ctx *context.Context, formatType string) {
 	}
 
 	feed := &feeds.Feed{
-		Title:       ctx.Tr("home.feed_of", ctx.ContextUser.DisplayName()),
+		Title:       ctx.Locale.TrString("home.feed_of", ctx.ContextUser.DisplayName()),
 		Link:        &feeds.Link{Href: ctx.ContextUser.HTMLURL()},
 		Description: ctxUserDescription,
 		Created:     time.Now(),
diff --git a/routers/web/feed/release.go b/routers/web/feed/release.go
index 57b0c92766..558c03dba7 100644
--- a/routers/web/feed/release.go
+++ b/routers/web/feed/release.go
@@ -28,10 +28,10 @@ func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleas
 	var link *feeds.Link
 
 	if isReleasesOnly {
-		title = ctx.Tr("repo.release.releases_for", repo.FullName())
+		title = ctx.Locale.TrString("repo.release.releases_for", repo.FullName())
 		link = &feeds.Link{Href: repo.HTMLURL() + "/release"}
 	} else {
-		title = ctx.Tr("repo.release.tags_for", repo.FullName())
+		title = ctx.Locale.TrString("repo.release.tags_for", repo.FullName())
 		link = &feeds.Link{Href: repo.HTMLURL() + "/tags"}
 	}
 
diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go
index 5fcad26779..51c24510c7 100644
--- a/routers/web/feed/repo.go
+++ b/routers/web/feed/repo.go
@@ -27,7 +27,7 @@ func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType
 	}
 
 	feed := &feeds.Feed{
-		Title:       ctx.Tr("home.feed_of", repo.FullName()),
+		Title:       ctx.Locale.TrString("home.feed_of", repo.FullName()),
 		Link:        &feeds.Link{Href: repo.HTMLURL()},
 		Description: repo.Description,
 		Created:     time.Now(),
diff --git a/routers/web/org/org.go b/routers/web/org/org.go
index 52f8df8a1c..1e4544730e 100644
--- a/routers/web/org/org.go
+++ b/routers/web/org/org.go
@@ -29,7 +29,7 @@ func Create(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("new_org")
 	ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode
 	if !ctx.Doer.CanCreateOrganization() {
-		ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed")))
+		ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed")))
 		return
 	}
 	ctx.HTML(http.StatusOK, tplCreateOrg)
@@ -41,7 +41,7 @@ func CreatePost(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("new_org")
 
 	if !ctx.Doer.CanCreateOrganization() {
-		ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed")))
+		ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed")))
 		return
 	}
 
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index f65cc6e679..f062127d24 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -353,7 +353,7 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	if boards[0].ID == 0 {
-		boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
+		boards[0].Title = ctx.Locale.TrString("repo.projects.type.uncategorized")
 	}
 
 	issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards)
@@ -679,7 +679,7 @@ func MoveIssues(ctx *context.Context) {
 		board = &project_model.Board{
 			ID:        0,
 			ProjectID: project.ID,
-			Title:     ctx.Tr("repo.projects.type.uncategorized"),
+			Title:     ctx.Locale.TrString("repo.projects.type.uncategorized"),
 		}
 	} else {
 		board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go
index 5f6a1ec36a..19aca26711 100644
--- a/routers/web/repo/actions/actions.go
+++ b/routers/web/repo/actions/actions.go
@@ -100,7 +100,7 @@ func List(ctx *context.Context) {
 			}
 			wf, err := model.ReadWorkflow(bytes.NewReader(content))
 			if err != nil {
-				workflow.ErrMsg = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", err.Error())
+				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
 				workflows = append(workflows, workflow)
 				continue
 			}
@@ -115,7 +115,7 @@ func List(ctx *context.Context) {
 						continue
 					}
 					if !allRunnerLabels.Contains(ro) {
-						workflow.ErrMsg = ctx.Locale.Tr("actions.runs.no_matching_online_runner_helper", ro)
+						workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro)
 						break
 					}
 				}
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 94fdd79903..ba2e63c3cc 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -209,8 +209,8 @@ func ViewPost(ctx *context_module.Context) {
 		Link: run.RefLink(),
 	}
 	resp.State.Run.Commit = ViewCommit{
-		LocaleCommit:   ctx.Tr("actions.runs.commit"),
-		LocalePushedBy: ctx.Tr("actions.runs.pushed_by"),
+		LocaleCommit:   ctx.Locale.TrString("actions.runs.commit"),
+		LocalePushedBy: ctx.Locale.TrString("actions.runs.pushed_by"),
 		ShortSha:       base.ShortSha(run.CommitSHA),
 		Link:           fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA),
 		Pusher:         pusher,
@@ -235,7 +235,7 @@ func ViewPost(ctx *context_module.Context) {
 	resp.State.CurrentJob.Title = current.Name
 	resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
 	if run.NeedApproval {
-		resp.State.CurrentJob.Detail = ctx.Locale.Tr("actions.need_approval_desc")
+		resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
 	}
 	resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
 	resp.Logs.StepsLog = make([]*ViewStepLog, 0)          // marshal to '[]' instead fo 'null' in json
diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go
index 25dd881219..8de54d569f 100644
--- a/routers/web/repo/cherry_pick.go
+++ b/routers/web/repo/cherry_pick.go
@@ -104,9 +104,9 @@ func CherryPickPost(ctx *context.Context) {
 	message := strings.TrimSpace(form.CommitSummary)
 	if message == "" {
 		if form.Revert {
-			message = ctx.Tr("repo.commit.revert-header", sha)
+			message = ctx.Locale.TrString("repo.commit.revert-header", sha)
 		} else {
-			message = ctx.Tr("repo.commit.cherry-pick-header", sha)
+			message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha)
 		}
 	}
 
diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go
index a3593815b8..67d41cf807 100644
--- a/routers/web/repo/compare.go
+++ b/routers/web/repo/compare.go
@@ -126,7 +126,7 @@ func setCsvCompareContext(ctx *context.Context) {
 			return CsvDiffResult{nil, ""}
 		}
 
-		errTooLarge := errors.New(ctx.Locale.Tr("repo.error.csv.too_large"))
+		errTooLarge := errors.New(ctx.Locale.TrString("repo.error.csv.too_large"))
 
 		csvReaderFromCommit := func(ctx *markup.RenderContext, blob *git.Blob) (*csv.Reader, io.Closer, error) {
 			if blob == nil {
diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go
index 39d9967d02..7a0501604e 100644
--- a/routers/web/repo/editor.go
+++ b/routers/web/repo/editor.go
@@ -300,9 +300,9 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
 	message := strings.TrimSpace(form.CommitSummary)
 	if len(message) == 0 {
 		if isNewFile {
-			message = ctx.Tr("repo.editor.add", form.TreePath)
+			message = ctx.Locale.TrString("repo.editor.add", form.TreePath)
 		} else {
-			message = ctx.Tr("repo.editor.update", form.TreePath)
+			message = ctx.Locale.TrString("repo.editor.update", form.TreePath)
 		}
 	}
 	form.CommitMessage = strings.TrimSpace(form.CommitMessage)
@@ -479,7 +479,7 @@ func DiffPreviewPost(ctx *context.Context) {
 	}
 
 	if diff.NumFiles == 0 {
-		ctx.PlainText(http.StatusOK, ctx.Tr("repo.editor.no_changes_to_show"))
+		ctx.PlainText(http.StatusOK, ctx.Locale.TrString("repo.editor.no_changes_to_show"))
 		return
 	}
 	ctx.Data["File"] = diff.Files[0]
@@ -546,7 +546,7 @@ func DeleteFilePost(ctx *context.Context) {
 
 	message := strings.TrimSpace(form.CommitSummary)
 	if len(message) == 0 {
-		message = ctx.Tr("repo.editor.delete", ctx.Repo.TreePath)
+		message = ctx.Locale.TrString("repo.editor.delete", ctx.Repo.TreePath)
 	}
 	form.CommitMessage = strings.TrimSpace(form.CommitMessage)
 	if len(form.CommitMessage) > 0 {
@@ -755,7 +755,7 @@ func UploadFilePost(ctx *context.Context) {
 		if dir == "" {
 			dir = "/"
 		}
-		message = ctx.Tr("repo.editor.upload_files_to_dir", dir)
+		message = ctx.Locale.TrString("repo.editor.upload_files_to_dir", dir)
 	}
 
 	form.CommitMessage = strings.TrimSpace(form.CommitMessage)
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index fb4f2bad98..7b171510bb 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1042,7 +1042,7 @@ func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) string
 	})
 	if err != nil {
 		log.Debug("render flash error: %v", err)
-		flashError = ctx.Tr("repo.issues.choose.ignore_invalid_templates")
+		flashError = ctx.Locale.TrString("repo.issues.choose.ignore_invalid_templates")
 	}
 	return flashError
 }
@@ -1664,7 +1664,7 @@ func ViewIssue(ctx *context.Context) {
 			}
 			ghostMilestone := &issues_model.Milestone{
 				ID:   -1,
-				Name: ctx.Tr("repo.issues.deleted_milestone"),
+				Name: ctx.Locale.TrString("repo.issues.deleted_milestone"),
 			}
 			if comment.OldMilestoneID > 0 && comment.OldMilestone == nil {
 				comment.OldMilestone = ghostMilestone
@@ -1681,7 +1681,7 @@ func ViewIssue(ctx *context.Context) {
 
 			ghostProject := &project_model.Project{
 				ID:    -1,
-				Title: ctx.Tr("repo.issues.deleted_project"),
+				Title: ctx.Locale.TrString("repo.issues.deleted_project"),
 			}
 
 			if comment.OldProjectID > 0 && comment.OldProject == nil {
diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go
index 31e6ac608c..4dc537a06e 100644
--- a/routers/web/repo/issue_content_history.go
+++ b/routers/web/repo/issue_content_history.go
@@ -56,12 +56,12 @@ func GetContentHistoryList(ctx *context.Context) {
 	for _, item := range items {
 		var actionText string
 		if item.IsDeleted {
-			actionTextDeleted := ctx.Locale.Tr("repo.issues.content_history.deleted")
+			actionTextDeleted := ctx.Locale.TrString("repo.issues.content_history.deleted")
 			actionText = "<i data-history-is-deleted='1'>" + actionTextDeleted + "</i>"
 		} else if item.IsFirstCreated {
-			actionText = ctx.Locale.Tr("repo.issues.content_history.created")
+			actionText = ctx.Locale.TrString("repo.issues.content_history.created")
 		} else {
-			actionText = ctx.Locale.Tr("repo.issues.content_history.edited")
+			actionText = ctx.Locale.TrString("repo.issues.content_history.edited")
 		}
 
 		username := item.UserName
diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go
index e0d49e44e1..742f12114d 100644
--- a/routers/web/repo/issue_label_test.go
+++ b/routers/web/repo/issue_label_test.go
@@ -123,7 +123,7 @@ func TestDeleteLabel(t *testing.T) {
 	assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
 	unittest.AssertNotExistsBean(t, &issues_model.Label{ID: 2})
 	unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{LabelID: 2})
-	assert.Equal(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg)
+	assert.EqualValues(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg)
 }
 
 func TestUpdateIssueLabel_Clear(t *testing.T) {
diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go
index c04435cf1b..00bd45aaec 100644
--- a/routers/web/repo/patch.go
+++ b/routers/web/repo/patch.go
@@ -79,7 +79,7 @@ func NewDiffPatchPost(ctx *context.Context) {
 	// `message` will be both the summary and message combined
 	message := strings.TrimSpace(form.CommitSummary)
 	if len(message) == 0 {
-		message = ctx.Tr("repo.editor.patch")
+		message = ctx.Locale.TrString("repo.editor.patch")
 	}
 
 	form.CommitMessage = strings.TrimSpace(form.CommitMessage)
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 001f0752c3..cc0127e7e1 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -315,7 +315,7 @@ func ViewProject(ctx *context.Context) {
 	}
 
 	if boards[0].ID == 0 {
-		boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
+		boards[0].Title = ctx.Locale.TrString("repo.projects.type.uncategorized")
 	}
 
 	issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards)
@@ -633,7 +633,7 @@ func MoveIssues(ctx *context.Context) {
 		board = &project_model.Board{
 			ID:        0,
 			ProjectID: project.ID,
-			Title:     ctx.Tr("repo.projects.type.uncategorized"),
+			Title:     ctx.Locale.TrString("repo.projects.type.uncategorized"),
 		}
 	} else {
 		board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 33aab4e2ce..ca854a35f2 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -733,7 +733,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C
 type pullCommitList struct {
 	Commits             []pull_service.CommitInfo `json:"commits"`
 	LastReviewCommitSha string                    `json:"last_review_commit_sha"`
-	Locale              map[string]string         `json:"locale"`
+	Locale              map[string]any            `json:"locale"`
 }
 
 // GetPullCommits get all commits for given pull request
@@ -751,7 +751,7 @@ func GetPullCommits(ctx *context.Context) {
 	}
 
 	// Get the needed locale
-	resp.Locale = map[string]string{
+	resp.Locale = map[string]any{
 		"lang":                                ctx.Locale.Language(),
 		"show_all_commits":                    ctx.Tr("repo.pulls.show_all_commits"),
 		"stats_num_commits":                   ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)),
diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go
index a5e0c69471..6c193b9aea 100644
--- a/routers/web/repo/pull_review.go
+++ b/routers/web/repo/pull_review.go
@@ -209,9 +209,9 @@ func SubmitReview(ctx *context.Context) {
 		if issue.IsPoster(ctx.Doer.ID) {
 			var translated string
 			if reviewType == issues_model.ReviewTypeApprove {
-				translated = ctx.Tr("repo.issues.review.self.approval")
+				translated = ctx.Locale.TrString("repo.issues.review.self.approval")
 			} else {
-				translated = ctx.Tr("repo.issues.review.self.rejection")
+				translated = ctx.Locale.TrString("repo.issues.review.self.rejection")
 			}
 
 			ctx.Flash.Error(translated)
diff --git a/routers/web/repo/setting/avatar.go b/routers/web/repo/setting/avatar.go
index 02c807b775..44468d2666 100644
--- a/routers/web/repo/setting/avatar.go
+++ b/routers/web/repo/setting/avatar.go
@@ -38,7 +38,7 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
 	defer r.Close()
 
 	if form.Avatar.Size > setting.Avatar.MaxFileSize {
-		return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
+		return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
 	}
 
 	data, err := io.ReadAll(r)
@@ -47,7 +47,7 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
 	}
 	st := typesniffer.DetectContentType(data)
 	if !(st.IsImage() && !st.IsSvgImage()) {
-		return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image"))
+		return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
 	}
 	if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil {
 		return fmt.Errorf("UploadAvatar: %w", err)
diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go
index 98d6977b81..85068f0ab2 100644
--- a/routers/web/repo/setting/protected_branch.go
+++ b/routers/web/repo/setting/protected_branch.go
@@ -68,7 +68,7 @@ func SettingsProtectedBranch(c *context.Context) {
 	}
 
 	c.Data["PageIsSettingsBranches"] = true
-	c.Data["Title"] = c.Tr("repo.settings.protected_branch") + " - " + rule.RuleName
+	c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName
 
 	users, err := access_model.GetRepoReaders(c, c.Repo.Repository)
 	if err != nil {
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 3113e1ac3c..e48865a2f5 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -743,7 +743,7 @@ func checkHomeCodeViewable(ctx *context.Context) {
 		}
 	}
 
-	ctx.NotFound("Home", fmt.Errorf(ctx.Tr("units.error.no_unit_allowed_repo")))
+	ctx.NotFound("Home", fmt.Errorf(ctx.Locale.TrString("units.error.no_unit_allowed_repo")))
 }
 
 func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) {
diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go
index 2c445fe7fa..79f446ea88 100644
--- a/routers/web/repo/wiki.go
+++ b/routers/web/repo/wiki.go
@@ -714,7 +714,7 @@ func NewWikiPost(ctx *context.Context) {
 	wikiName := wiki_service.UserTitleToWebPath("", form.Title)
 
 	if len(form.Message) == 0 {
-		form.Message = ctx.Tr("repo.editor.add", form.Title)
+		form.Message = ctx.Locale.TrString("repo.editor.add", form.Title)
 	}
 
 	if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Content, form.Message); err != nil {
@@ -766,7 +766,7 @@ func EditWikiPost(ctx *context.Context) {
 	newWikiName := wiki_service.UserTitleToWebPath("", form.Title)
 
 	if len(form.Message) == 0 {
-		form.Message = ctx.Tr("repo.editor.update", form.Title)
+		form.Message = ctx.Locale.TrString("repo.editor.update", form.Title)
 	}
 
 	if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.Content, form.Message); err != nil {
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index 115c995e21..8759ba32b1 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -85,7 +85,7 @@ func Dashboard(ctx *context.Context) {
 		page = 1
 	}
 
-	ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard")
+	ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard")
 	ctx.Data["PageIsDashboard"] = true
 	ctx.Data["PageIsNews"] = true
 	cnt, _ := organization.GetOrganizationCount(ctx, ctxUser)
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
index 95b350528c..24a807d518 100644
--- a/routers/web/user/setting/profile.go
+++ b/routers/web/user/setting/profile.go
@@ -126,7 +126,7 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *
 		defer fr.Close()
 
 		if form.Avatar.Size > setting.Avatar.MaxFileSize {
-			return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
+			return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
 		}
 
 		data, err := io.ReadAll(fr)
@@ -136,7 +136,7 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *
 
 		st := typesniffer.DetectContentType(data)
 		if !(st.IsImage() && !st.IsSvgImage()) {
-			return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image"))
+			return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
 		}
 		if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil {
 			return fmt.Errorf("UploadAvatar: %w", err)
@@ -389,7 +389,7 @@ func UpdateUserLang(ctx *context.Context) {
 	middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0)
 
 	log.Trace("User settings updated: %s", ctx.Doer.Name)
-	ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).Tr("settings.update_language_success"))
+	ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success"))
 	ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
 }
 
diff --git a/routers/web/user/task.go b/routers/web/user/task.go
index f35f40e6a0..bec68c5f20 100644
--- a/routers/web/user/task.go
+++ b/routers/web/user/task.go
@@ -39,7 +39,7 @@ func TaskStatus(ctx *context.Context) {
 				Args:   []any{task.Message},
 			}
 		}
-		message = ctx.Tr(translatableMessage.Format, translatableMessage.Args...)
+		message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...)
 	}
 
 	ctx.JSON(http.StatusOK, map[string]any{
diff --git a/routers/web/web.go b/routers/web/web.go
index 23980d522d..caea7bdd1e 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -152,7 +152,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
 			if ctx.Doer.MustChangePassword {
 				if ctx.Req.URL.Path != "/user/settings/change_password" {
 					if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
-						ctx.Error(http.StatusUnauthorized, ctx.Tr("auth.must_change_password"))
+						ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
 						return
 					}
 					ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
diff --git a/services/cron/setting.go b/services/cron/setting.go
index 0656307cba..6dad88830a 100644
--- a/services/cron/setting.go
+++ b/services/cron/setting.go
@@ -70,7 +70,7 @@ func (b *BaseConfig) DoNoticeOnSuccess() bool {
 // Please note the `status` string will be concatenated with `admin.dashboard.cron.` and `admin.dashboard.task.` to provide locale messages. Similarly `name` will be composed with `admin.dashboard.` to provide the locale name for the task.
 func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer string, args ...any) string {
 	realArgs := make([]any, 0, len(args)+2)
-	realArgs = append(realArgs, locale.Tr("admin.dashboard."+name))
+	realArgs = append(realArgs, locale.TrString("admin.dashboard."+name))
 	if doer == "" {
 		realArgs = append(realArgs, "(Cron)")
 	} else {
@@ -80,7 +80,7 @@ func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer
 		realArgs = append(realArgs, args...)
 	}
 	if doer == "" {
-		return locale.Tr("admin.dashboard.cron."+status, realArgs...)
+		return locale.TrString("admin.dashboard.cron."+status, realArgs...)
 	}
-	return locale.Tr("admin.dashboard.task."+status, realArgs...)
+	return locale.TrString("admin.dashboard.task."+status, realArgs...)
 }
diff --git a/services/cron/tasks.go b/services/cron/tasks.go
index f0956a97d8..f8a7444c49 100644
--- a/services/cron/tasks.go
+++ b/services/cron/tasks.go
@@ -159,7 +159,7 @@ func RegisterTask(name string, config Config, fun func(context.Context, *user_mo
 	log.Debug("Registering task: %s", name)
 
 	i18nKey := "admin.dashboard." + name
-	if value := translation.NewLocale("en-US").Tr(i18nKey); value == i18nKey {
+	if value := translation.NewLocale("en-US").TrString(i18nKey); value == i18nKey {
 		return fmt.Errorf("translation is missing for task %q, please add translation for %q", name, i18nKey)
 	}
 
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 6ecb4ea768..ff6fad4226 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -325,7 +325,7 @@ func (f *NewSlackHookForm) Validate(req *http.Request, errs binding.Errors) bind
 		errs = append(errs, binding.Error{
 			FieldNames:     []string{"Channel"},
 			Classification: "",
-			Message:        ctx.Tr("repo.settings.add_webhook.invalid_channel_name"),
+			Message:        ctx.Locale.TrString("repo.settings.add_webhook.invalid_channel_name"),
 		})
 	}
 	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index 7b64eead2f..f72ae45f89 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -94,7 +94,7 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
 		// No mail service configured
 		return
 	}
-	sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.activate_account"), "activate account")
+	sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
 }
 
 // SendResetPasswordMail sends a password reset mail to the user
@@ -104,7 +104,7 @@ func SendResetPasswordMail(u *user_model.User) {
 		return
 	}
 	locale := translation.NewLocale(u.Language)
-	sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.reset_password"), "recover account")
+	sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account")
 }
 
 // SendActivateEmailMail sends confirmation email to confirm new email address
@@ -130,7 +130,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
 		return
 	}
 
-	msg := NewMessage(email, locale.Tr("mail.activate_email"), content.String())
+	msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
 	msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
 
 	SendAsync(msg)
@@ -158,7 +158,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
 		return
 	}
 
-	msg := NewMessage(u.Email, locale.Tr("mail.register_notify"), content.String())
+	msg := NewMessage(u.Email, locale.TrString("mail.register_notify"), content.String())
 	msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
 
 	SendAsync(msg)
@@ -173,7 +173,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
 	locale := translation.NewLocale(u.Language)
 	repoName := repo.FullName()
 
-	subject := locale.Tr("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
+	subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
 	data := map[string]any{
 		"locale":   locale,
 		"Subject":  subject,
diff --git a/services/mailer/mail_admin_new_user.go b/services/mailer/mail_admin_new_user.go
index e9610e626a..ecf0ddf5fa 100644
--- a/services/mailer/mail_admin_new_user.go
+++ b/services/mailer/mail_admin_new_user.go
@@ -52,8 +52,8 @@ func mailNewUser(ctx context.Context, u *user_model.User, lang string, tos []str
 	locale := translation.NewLocale(lang)
 
 	manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(u.ID, 10)
-	subject := locale.Tr("mail.admin.new_user.subject", u.Name)
-	body := locale.Tr("mail.admin.new_user.text", manageUserURL)
+	subject := locale.TrString("mail.admin.new_user.subject", u.Name)
+	body := locale.TrString("mail.admin.new_user.text", manageUserURL)
 	mailMeta := map[string]any{
 		"NewUser":    u,
 		"NewUserUrl": u.HTMLURL(),
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index 5e8e5b6af3..6682774a04 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -68,7 +68,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo
 		return
 	}
 
-	subject := locale.Tr("mail.release.new.subject", rel.TagName, rel.Repo.FullName())
+	subject := locale.TrString("mail.release.new.subject", rel.TagName, rel.Repo.FullName())
 	mailMeta := map[string]any{
 		"locale":   locale,
 		"Release":  rel,
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index b89dcd43b5..e0d55bb120 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -56,11 +56,11 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
 		content bytes.Buffer
 	)
 
-	destination := locale.Tr("mail.repo.transfer.to_you")
-	subject := locale.Tr("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName())
+	destination := locale.TrString("mail.repo.transfer.to_you")
+	subject := locale.TrString("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName())
 	if newOwner.IsOrganization() {
 		destination = newOwner.DisplayName()
-		subject = locale.Tr("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination)
+		subject = locale.TrString("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination)
 	}
 
 	data := map[string]any{
diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go
index ab32beefac..ceecefa50f 100644
--- a/services/mailer/mail_team_invite.go
+++ b/services/mailer/mail_team_invite.go
@@ -50,7 +50,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
 		inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect)
 	}
 
-	subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName())
+	subject := locale.TrString("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName())
 	mailMeta := map[string]any{
 		"locale":       locale,
 		"Inviter":      inviter,
diff --git a/templates/mail/issue/assigned.tmpl b/templates/mail/issue/assigned.tmpl
index d02ea39918..e80bd2fc31 100644
--- a/templates/mail/issue/assigned.tmpl
+++ b/templates/mail/issue/assigned.tmpl
@@ -13,9 +13,9 @@
 <body>
 	<p>
 		{{if .IsPull}}
-			{{.locale.Tr "mail.issue_assigned.pull" .Doer.Name $link $repo_url | Str2html}}
+			{{.locale.Tr "mail.issue_assigned.pull" .Doer.Name ($link|Safe) ($repo_url|Safe)}}
 		{{else}}
-			{{.locale.Tr "mail.issue_assigned.issue" .Doer.Name $link $repo_url | Str2html}}
+			{{.locale.Tr "mail.issue_assigned.issue" .Doer.Name ($link|Safe) ($repo_url|Safe)}}
 		{{end}}
 	</p>
 	<div class="footer">
diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl
index 422a4f0461..b5a7ab95cf 100644
--- a/templates/mail/issue/default.tmpl
+++ b/templates/mail/issue/default.tmpl
@@ -28,7 +28,7 @@
 				{{$newShortSha := ShortSha .Comment.NewCommit}}
 				{{$newCommitLink := printf "<a href='%[1]s'><b>%[2]s</b></a>" (Escape $newCommitUrl) (Escape $newShortSha)}}
 
-				{{.locale.Tr "mail.issue.action.force_push" .Doer.Name .Comment.Issue.PullRequest.HeadBranch $oldCommitLink $newCommitLink | Str2html}}
+				{{.locale.Tr "mail.issue.action.force_push" .Doer.Name .Comment.Issue.PullRequest.HeadBranch ($oldCommitLink|Safe) ($newCommitLink|Safe)}}
 			{{else}}
 				{{.locale.TrN (len .Comment.Commits) "mail.issue.action.push_1" "mail.issue.action.push_n" .Doer.Name .Comment.Issue.PullRequest.HeadBranch (len .Comment.Commits) | Str2html}}
 			{{end}}
diff --git a/templates/mail/notify/repo_transfer.tmpl b/templates/mail/notify/repo_transfer.tmpl
index 43d95b3ff0..1b23593f6b 100644
--- a/templates/mail/notify/repo_transfer.tmpl
+++ b/templates/mail/notify/repo_transfer.tmpl
@@ -8,7 +8,7 @@
 {{$url := printf "<a href='%[1]s'>%[2]s</a>" (Escape .Link) (Escape .Repo)}}
 <body>
 	<p>{{.Subject}}.
-		{{.locale.Tr "mail.repo.transfer.body" $url | Str2html}}
+		{{.locale.Tr "mail.repo.transfer.body" ($url|Safe)}}
 	</p>
 	<p>
 		---
diff --git a/templates/mail/release.tmpl b/templates/mail/release.tmpl
index f588d8224f..96dc769993 100644
--- a/templates/mail/release.tmpl
+++ b/templates/mail/release.tmpl
@@ -15,7 +15,7 @@
 {{$repo_url := printf "<a href='%s'>%s</a>" (.Release.Repo.HTMLURL | Escape) (.Release.Repo.FullName | Escape)}}
 <body>
 	<p>
-		{{.locale.Tr "mail.release.new.text" .Release.Publisher.Name $release_url $repo_url | Str2html}}
+		{{.locale.Tr "mail.release.new.text" .Release.Publisher.Name ($release_url|Safe) ($repo_url|Safe)}}
 	</p>
 	<h4>{{.locale.Tr "mail.release.title" .Release.Title}}</h4>
 	<p>
diff --git a/templates/repo/editor/cherry_pick.tmpl b/templates/repo/editor/cherry_pick.tmpl
index ab2c3c3349..b65c3a3033 100644
--- a/templates/repo/editor/cherry_pick.tmpl
+++ b/templates/repo/editor/cherry_pick.tmpl
@@ -13,9 +13,9 @@
 					{{$shaurl := printf "%s/commit/%s" $.RepoLink (PathEscape .SHA)}}
 					{{$shalink := printf `<a class="ui primary sha label" href="%s">%s</a>` (Escape $shaurl) (ShortSha .SHA)}}
 					{{if eq .CherryPickType "revert"}}
-						{{ctx.Locale.Tr "repo.editor.revert" $shalink | Str2html}}
+						{{ctx.Locale.Tr "repo.editor.revert" ($shalink|Safe)}}
 					{{else}}
-						{{ctx.Locale.Tr "repo.editor.cherry_pick" $shalink | Str2html}}
+						{{ctx.Locale.Tr "repo.editor.cherry_pick" ($shalink|Safe)}}
 					{{end}}
 					<a class="section" href="{{$.RepoLink}}">{{.Repository.FullName}}</a>
 					<div class="breadcrumb-divider">:</div>
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index a8e0aaf430..f71ee2f11b 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -595,11 +595,11 @@
 						{{$newProjectDisplayHtml = printf `<span data-tooltip-content="%s">%s</span>` (ctx.Locale.Tr $trKey | Escape) (.Project.Title | Escape)}}
 					{{end}}
 					{{if and (gt .OldProjectID 0) (gt .ProjectID 0)}}
-						{{ctx.Locale.Tr "repo.issues.change_project_at" $oldProjectDisplayHtml $newProjectDisplayHtml $createdStr | Safe}}
+						{{ctx.Locale.Tr "repo.issues.change_project_at" ($oldProjectDisplayHtml|Safe) ($newProjectDisplayHtml|Safe) $createdStr}}
 					{{else if gt .OldProjectID 0}}
-						{{ctx.Locale.Tr "repo.issues.remove_project_at" $oldProjectDisplayHtml $createdStr | Safe}}
+						{{ctx.Locale.Tr "repo.issues.remove_project_at" ($oldProjectDisplayHtml|Safe) $createdStr}}
 					{{else if gt .ProjectID 0}}
-						{{ctx.Locale.Tr "repo.issues.add_project_at" $newProjectDisplayHtml $createdStr | Safe}}
+						{{ctx.Locale.Tr "repo.issues.add_project_at" ($newProjectDisplayHtml|Safe) $createdStr}}
 					{{end}}
 				</span>
 			</div>
diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl
index 7ec48c6734..582e9864fb 100644
--- a/templates/repo/issue/view_title.tmpl
+++ b/templates/repo/issue/view_title.tmpl
@@ -56,18 +56,18 @@
 					{{$mergedStr:= TimeSinceUnix .Issue.PullRequest.MergedUnix ctx.Locale}}
 					{{if .Issue.OriginalAuthor}}
 						{{.Issue.OriginalAuthor}}
-						<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr | Safe}}</span>
+						<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits ($headHref|Safe) ($baseHref|Safe) $mergedStr}}</span>
 					{{else}}
 						<a {{if gt .Issue.PullRequest.Merger.ID 0}}href="{{.Issue.PullRequest.Merger.HomeLink}}"{{end}}>{{.Issue.PullRequest.Merger.GetDisplayName}}</a>
-						<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr | Safe}}</span>
+						<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits ($headHref|Safe) ($baseHref|Safe) $mergedStr}}</span>
 					{{end}}
 				{{else}}
 					{{if .Issue.OriginalAuthor}}
-						<span id="pull-desc" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref | Safe}}</span>
+						<span id="pull-desc" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits ($headHref|Safe) ($baseHref|Safe)}}</span>
 					{{else}}
 						<span id="pull-desc" class="pull-desc">
 							<a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.GetDisplayName}}</a>
-							{{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref | Safe}}
+							{{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits ($headHref|Safe) ($baseHref|Safe)}}
 						</span>
 					{{end}}
 					<span id="pull-desc-edit" class="gt-hidden flex-text-block">
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go
index 1148b3ad39..2d69dfcfd7 100644
--- a/tests/integration/auth_ldap_test.go
+++ b/tests/integration/auth_ldap_test.go
@@ -309,7 +309,7 @@ func TestLDAPUserSyncWithGroupFilter(t *testing.T) {
 	// all groups the user is a member of, the user filter is modified accordingly inside
 	// the addAuthSourceLDAP based on the value of the groupFilter
 	u := otherLDAPUsers[0]
-	testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").Tr("form.username_password_incorrect"))
+	testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect"))
 
 	auth.SyncExternalUsers(context.Background(), true)
 
@@ -362,7 +362,7 @@ func TestLDAPUserSigninFailed(t *testing.T) {
 	addAuthSourceLDAP(t, "", "")
 
 	u := otherLDAPUsers[0]
-	testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").Tr("form.username_password_incorrect"))
+	testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect"))
 }
 
 func TestLDAPUserSSHKeySync(t *testing.T) {
diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go
index 1c5c8f6d45..ee13a4bc57 100644
--- a/tests/integration/pull_merge_test.go
+++ b/tests/integration/pull_merge_test.go
@@ -218,7 +218,7 @@ func TestCantMergeWorkInProgress(t *testing.T) {
 		text := strings.TrimSpace(htmlDoc.doc.Find(".merge-section > .item").Last().Text())
 		assert.NotEmpty(t, text, "Can't find WIP text")
 
-		assert.Contains(t, text, translation.NewLocale("en-US").Tr("repo.pulls.cannot_merge_work_in_progress"), "Unable to find WIP text")
+		assert.Contains(t, text, translation.NewLocale("en-US").TrString("repo.pulls.cannot_merge_work_in_progress"), "Unable to find WIP text")
 		assert.Contains(t, text, "[wip]", "Unable to find WIP text")
 	})
 }
diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go
index 96fcff0963..3ae2703afd 100644
--- a/tests/integration/release_test.go
+++ b/tests/integration/release_test.go
@@ -90,7 +90,7 @@ func TestCreateRelease(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, false)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").Tr("repo.release.stable"), 4)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4)
 }
 
 func TestDeleteRelease(t *testing.T) {
@@ -137,7 +137,7 @@ func TestCreateReleasePreRelease(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", true, false)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").Tr("repo.release.prerelease"), 4)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.prerelease"), 4)
 }
 
 func TestCreateReleaseDraft(t *testing.T) {
@@ -146,7 +146,7 @@ func TestCreateReleaseDraft(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, true)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").Tr("repo.release.draft"), 4)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.draft"), 4)
 }
 
 func TestCreateReleasePaging(t *testing.T) {
@@ -166,11 +166,11 @@ func TestCreateReleasePaging(t *testing.T) {
 	}
 	createNewRelease(t, session, "/user2/repo1", "v0.0.12", "v0.0.12", false, true)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").Tr("repo.release.draft"), 10)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").TrString("repo.release.draft"), 10)
 
 	// Check that user4 does not see draft and still see 10 latest releases
 	session2 := loginUser(t, "user4")
-	checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").Tr("repo.release.stable"), 10)
+	checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").TrString("repo.release.stable"), 10)
 }
 
 func TestViewReleaseListNoLogin(t *testing.T) {
@@ -265,7 +265,7 @@ func TestReleaseOnCommit(t *testing.T) {
 	session := loginUser(t, "user2")
 	createNewReleaseTarget(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", "65f1bf27bc3bf70f64657658635e66094edbcb4d", false, false)
 
-	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").Tr("repo.release.stable"), 4)
+	checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4)
 }
 
 func TestViewTagsList(t *testing.T) {
diff --git a/tests/integration/repo_branch_test.go b/tests/integration/repo_branch_test.go
index d6b2b39d9a..99d4f16d56 100644
--- a/tests/integration/repo_branch_test.go
+++ b/tests/integration/repo_branch_test.go
@@ -63,39 +63,39 @@ func testCreateBranches(t *testing.T, giteaURL *url.URL) {
 			OldRefSubURL:   "branch/master",
 			NewBranch:      "feature/test1",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.create_success", "feature/test1"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test1"),
 			CheckBranch:    true,
 		},
 		{
 			OldRefSubURL:   "branch/master",
 			NewBranch:      "",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("form.NewBranchName") + translation.NewLocale("en-US").Tr("form.require_error"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.require_error"),
 		},
 		{
 			OldRefSubURL:   "branch/master",
 			NewBranch:      "feature=test1",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.create_success", "feature=test1"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature=test1"),
 			CheckBranch:    true,
 		},
 		{
 			OldRefSubURL:   "branch/master",
 			NewBranch:      strings.Repeat("b", 101),
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("form.NewBranchName") + translation.NewLocale("en-US").Tr("form.max_size_error", "100"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.max_size_error", "100"),
 		},
 		{
 			OldRefSubURL:   "branch/master",
 			NewBranch:      "master",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.branch_already_exists", "master"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.branch_already_exists", "master"),
 		},
 		{
 			OldRefSubURL:   "branch/master",
 			NewBranch:      "master/test",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.branch_name_conflict", "master/test", "master"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.branch_name_conflict", "master/test", "master"),
 		},
 		{
 			OldRefSubURL:   "commit/acd1d892867872cb47f3993468605b8aa59aa2e0",
@@ -106,7 +106,7 @@ func testCreateBranches(t *testing.T, giteaURL *url.URL) {
 			OldRefSubURL:   "commit/65f1bf27bc3bf70f64657658635e66094edbcb4d",
 			NewBranch:      "feature/test3",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.create_success", "feature/test3"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test3"),
 			CheckBranch:    true,
 		},
 		{
@@ -114,14 +114,14 @@ func testCreateBranches(t *testing.T, giteaURL *url.URL) {
 			NewBranch:      "v1.0.0",
 			CreateRelease:  "v1.0.0",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.tag_collision", "v1.0.0"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.tag_collision", "v1.0.0"),
 		},
 		{
 			OldRefSubURL:   "tag/v1.0.0",
 			NewBranch:      "feature/test4",
 			CreateRelease:  "v1.0.1",
 			ExpectedStatus: http.StatusSeeOther,
-			FlashMessage:   translation.NewLocale("en-US").Tr("repo.branch.create_success", "feature/test4"),
+			FlashMessage:   translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test4"),
 			CheckBranch:    true,
 		},
 	}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 2584b88f65..77e19bba96 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -49,10 +49,10 @@ func TestSignin(t *testing.T) {
 		password string
 		message  string
 	}{
-		{username: "wrongUsername", password: "wrongPassword", message: translation.NewLocale("en-US").Tr("form.username_password_incorrect")},
-		{username: "wrongUsername", password: "password", message: translation.NewLocale("en-US").Tr("form.username_password_incorrect")},
-		{username: "user15", password: "wrongPassword", message: translation.NewLocale("en-US").Tr("form.username_password_incorrect")},
-		{username: "user1@example.com", password: "wrongPassword", message: translation.NewLocale("en-US").Tr("form.username_password_incorrect")},
+		{username: "wrongUsername", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+		{username: "wrongUsername", password: "password", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+		{username: "user15", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+		{username: "user1@example.com", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
 	}
 
 	for _, s := range samples {
diff --git a/tests/integration/signup_test.go b/tests/integration/signup_test.go
index 8b0013419f..582cf6aae1 100644
--- a/tests/integration/signup_test.go
+++ b/tests/integration/signup_test.go
@@ -69,9 +69,9 @@ func TestSignupEmail(t *testing.T) {
 		wantStatus int
 		wantMsg    string
 	}{
-		{"exampleUser@example.com\r\n", http.StatusOK, translation.NewLocale("en-US").Tr("form.email_invalid")},
-		{"exampleUser@example.com\r", http.StatusOK, translation.NewLocale("en-US").Tr("form.email_invalid")},
-		{"exampleUser@example.com\n", http.StatusOK, translation.NewLocale("en-US").Tr("form.email_invalid")},
+		{"exampleUser@example.com\r\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
+		{"exampleUser@example.com\r", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
+		{"exampleUser@example.com\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
 		{"exampleUser@example.com", http.StatusSeeOther, ""},
 	}
 
diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go
index 0defa109ae..926322667c 100644
--- a/tests/integration/user_test.go
+++ b/tests/integration/user_test.go
@@ -85,7 +85,7 @@ func TestRenameInvalidUsername(t *testing.T) {
 		htmlDoc := NewHTMLParser(t, resp.Body)
 		assert.Contains(t,
 			htmlDoc.doc.Find(".ui.negative.message").Text(),
-			translation.NewLocale("en-US").Tr("form.username_error"),
+			translation.NewLocale("en-US").TrString("form.username_error"),
 		)
 
 		unittest.AssertNotExistsBean(t, &user_model.User{Name: invalidUsername})
@@ -147,7 +147,7 @@ func TestRenameReservedUsername(t *testing.T) {
 		htmlDoc := NewHTMLParser(t, resp.Body)
 		assert.Contains(t,
 			htmlDoc.doc.Find(".ui.negative.message").Text(),
-			translation.NewLocale("en-US").Tr("user.form.name_reserved", reservedUsername),
+			translation.NewLocale("en-US").TrString("user.form.name_reserved", reservedUsername),
 		)
 
 		unittest.AssertNotExistsBean(t, &user_model.User{Name: reservedUsername})