初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"gitea.dev/modules/assetfs"
"gitea.dev/modules/setting"
)
func AssetFS() *assetfs.LayeredFS {
return assetfs.Layered(CustomAssets(), BuiltinAssets())
}
func CustomAssets() *assetfs.Layer {
return assetfs.Local("custom", setting.CustomPath, "templates")
}
+344
View File
@@ -0,0 +1,344 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eval
import (
"fmt"
"strconv"
"strings"
"gitea.dev/modules/util"
)
type Num struct {
Value any // int64 or float64, nil on error
}
var opPrecedence = map[string]int{
// "(": 1, this is for low precedence like function calls, they are handled separately
"or": 2,
"and": 3,
"not": 4,
"==": 5, "!=": 5, "<": 5, "<=": 5, ">": 5, ">=": 5,
"+": 6, "-": 6,
"*": 7, "/": 7,
}
type stack[T any] struct {
name string
elems []T
}
func (s *stack[T]) push(t T) {
s.elems = append(s.elems, t)
}
func (s *stack[T]) pop() T {
if len(s.elems) == 0 {
panic(s.name + " stack is empty")
}
t := s.elems[len(s.elems)-1]
s.elems = s.elems[:len(s.elems)-1]
return t
}
func (s *stack[T]) peek() T {
if len(s.elems) == 0 {
panic(s.name + " stack is empty")
}
return s.elems[len(s.elems)-1]
}
type operator string
type eval struct {
stackNum stack[Num]
stackOp stack[operator]
funcMap map[string]func([]Num) Num
}
func newEval() *eval {
e := &eval{}
e.stackNum.name = "num"
e.stackOp.name = "op"
return e
}
func toNum(v any) (Num, error) {
switch v := v.(type) {
case string:
if strings.Contains(v, ".") {
n, err := strconv.ParseFloat(v, 64)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
case float32, float64:
n, _ := util.ToFloat64(v)
return Num{n}, nil
default:
n, err := util.ToInt64(v)
if err != nil {
return Num{n}, err
}
return Num{n}, nil
}
}
func truth(b bool) int64 {
if b {
return int64(1)
}
return int64(0)
}
func applyOp2Generic[T int64 | float64](op operator, n1, n2 T) Num {
switch op {
case "+":
return Num{n1 + n2}
case "-":
return Num{n1 - n2}
case "*":
return Num{n1 * n2}
case "/":
return Num{n1 / n2}
case "==":
return Num{truth(n1 == n2)}
case "!=":
return Num{truth(n1 != n2)}
case "<":
return Num{truth(n1 < n2)}
case "<=":
return Num{truth(n1 <= n2)}
case ">":
return Num{truth(n1 > n2)}
case ">=":
return Num{truth(n1 >= n2)}
case "and":
t1, _ := util.ToFloat64(n1)
t2, _ := util.ToFloat64(n2)
return Num{truth(t1 != 0 && t2 != 0)}
case "or":
t1, _ := util.ToFloat64(n1)
t2, _ := util.ToFloat64(n2)
return Num{truth(t1 != 0 || t2 != 0)}
}
panic("unknown operator: " + string(op))
}
func applyOp2(op operator, n1, n2 Num) Num {
float := false
if _, ok := n1.Value.(float64); ok {
float = true
} else if _, ok = n2.Value.(float64); ok {
float = true
}
if float {
f1, _ := util.ToFloat64(n1.Value)
f2, _ := util.ToFloat64(n2.Value)
return applyOp2Generic(op, f1, f2)
}
return applyOp2Generic(op, n1.Value.(int64), n2.Value.(int64))
}
func toOp(v any) (operator, error) {
if v, ok := v.(string); ok {
return operator(v), nil
}
return "", fmt.Errorf(`unsupported token type "%T"`, v)
}
func (op operator) hasOpenBracket() bool {
return strings.HasSuffix(string(op), "(") // it's used to support functions like "sum("
}
func (op operator) isComma() bool {
return op == ","
}
func (op operator) isCloseBracket() bool {
return op == ")"
}
type ExprError struct {
msg string
tokens []any
err error
}
func (err ExprError) Error() string {
sb := strings.Builder{}
sb.WriteString(err.msg)
sb.WriteString(" [ ")
for _, token := range err.tokens {
_, _ = fmt.Fprintf(&sb, `"%v" `, token)
}
sb.WriteString("]")
if err.err != nil {
sb.WriteString(": ")
sb.WriteString(err.err.Error())
}
return sb.String()
}
func (err ExprError) Unwrap() error {
return err.err
}
func (e *eval) applyOp() {
op := e.stackOp.pop()
if op == "not" {
num := e.stackNum.pop()
i, _ := util.ToInt64(num.Value)
e.stackNum.push(Num{truth(i == 0)})
} else if op.hasOpenBracket() || op.isCloseBracket() || op.isComma() {
panic(fmt.Sprintf("incomplete sub-expression with operator %q", op))
} else {
num2 := e.stackNum.pop()
num1 := e.stackNum.pop()
e.stackNum.push(applyOp2(op, num1, num2))
}
}
func (e *eval) exec(tokens ...any) (ret Num, err error) {
defer func() {
if r := recover(); r != nil {
rErr, ok := r.(error)
if !ok {
rErr = fmt.Errorf("%v", r)
}
err = ExprError{"invalid expression", tokens, rErr}
}
}()
for _, token := range tokens {
n, err := toNum(token)
if err == nil {
e.stackNum.push(n)
continue
}
op, err := toOp(token)
if err != nil {
return Num{}, ExprError{"invalid expression", tokens, err}
}
switch {
case op.hasOpenBracket():
e.stackOp.push(op)
case op.isCloseBracket(), op.isComma():
var stackTopOp operator
for len(e.stackOp.elems) > 0 {
stackTopOp = e.stackOp.peek()
if stackTopOp.hasOpenBracket() || stackTopOp.isComma() {
break
}
e.applyOp()
}
if op.isCloseBracket() {
nums := []Num{e.stackNum.pop()}
for !e.stackOp.peek().hasOpenBracket() {
stackTopOp = e.stackOp.pop()
if !stackTopOp.isComma() {
return Num{}, ExprError{"bracket doesn't match", tokens, nil}
}
nums = append(nums, e.stackNum.pop())
}
for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 {
nums[i], nums[j] = nums[j], nums[i] // reverse nums slice, to get the right order for arguments
}
stackTopOp = e.stackOp.pop()
fn := string(stackTopOp[:len(stackTopOp)-1])
if fn == "" {
if len(nums) != 1 {
return Num{}, ExprError{"too many values in one bracket", tokens, nil}
}
e.stackNum.push(nums[0])
} else if f, ok := e.funcMap[fn]; ok {
e.stackNum.push(f(nums))
} else {
return Num{}, ExprError{"unknown function: " + fn, tokens, nil}
}
} else {
e.stackOp.push(op)
}
default:
for len(e.stackOp.elems) > 0 && len(e.stackNum.elems) > 0 {
stackTopOp := e.stackOp.peek()
if stackTopOp.hasOpenBracket() || stackTopOp.isComma() || precedence(stackTopOp, op) < 0 {
break
}
e.applyOp()
}
e.stackOp.push(op)
}
}
for len(e.stackOp.elems) > 0 && !e.stackOp.peek().isComma() {
e.applyOp()
}
if len(e.stackNum.elems) != 1 {
return Num{}, ExprError{fmt.Sprintf("expect 1 value as final result, but there are %d", len(e.stackNum.elems)), tokens, nil}
}
return e.stackNum.pop(), nil
}
func precedence(op1, op2 operator) int {
p1 := opPrecedence[string(op1)]
p2 := opPrecedence[string(op2)]
if p1 == 0 {
panic("unknown operator precedence: " + string(op1))
} else if p2 == 0 {
panic("unknown operator precedence: " + string(op2))
}
return p1 - p2
}
func castFloat64(nums []Num) bool {
hasFloat := false
for _, num := range nums {
if _, hasFloat = num.Value.(float64); hasFloat {
break
}
}
if hasFloat {
for i, num := range nums {
if _, ok := num.Value.(float64); !ok {
f, _ := util.ToFloat64(num.Value)
nums[i] = Num{f}
}
}
}
return hasFloat
}
func fnSum(nums []Num) Num {
if castFloat64(nums) {
var sum float64
for _, num := range nums {
sum += num.Value.(float64)
}
return Num{sum}
}
var sum int64
for _, num := range nums {
sum += num.Value.(int64)
}
return Num{sum}
}
// Expr evaluates the given expression tokens and returns the result.
// It supports the following operators: +, -, *, /, and, or, not, ==, !=, >, >=, <, <=.
// Non-zero values are treated as true, zero values are treated as false.
// If no error occurs, the result is either an int64 or a float64.
// If all numbers are integer, the result is an int64, otherwise if there is any float number, the result is a float64.
func Expr(tokens ...any) (Num, error) {
e := newEval()
e.funcMap = map[string]func([]Num) Num{"sum": fnSum}
return e.exec(tokens...)
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eval
import (
"math"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func tokens(s string) (a []any) {
for v := range strings.FieldsSeq(s) {
a = append(a, v)
}
return a
}
func TestEval(t *testing.T) {
n, err := Expr(0, "/", 0.0)
assert.NoError(t, err)
assert.True(t, math.IsNaN(n.Value.(float64)))
_, err = Expr(nil)
assert.ErrorContains(t, err, "unsupported token type")
_, err = Expr([]string{})
assert.ErrorContains(t, err, "unsupported token type")
_, err = Expr(struct{}{})
assert.ErrorContains(t, err, "unsupported token type")
cases := []struct {
expr string
want any
}{
{"-1", int64(-1)},
{"1 + 2", int64(3)},
{"3 - 2 + 4", int64(5)},
{"1 + 2 * 3", int64(7)},
{"1 + ( 2 * 3 )", int64(7)},
{"( 1 + 2 ) * 3", int64(9)},
{"( 1 + 2.0 ) / 3", float64(1)},
{"sum( 1 , 2 , 3 , 4 )", int64(10)},
{"100 + sum( 1 , 2 + 3 , 0.0 ) / 2", float64(103)},
{"100 * 5 / ( 5 + 15 )", int64(25)},
{"9 == 5", int64(0)},
{"5 == 5", int64(1)},
{"9 != 5", int64(1)},
{"5 != 5", int64(0)},
{"9 > 5", int64(1)},
{"5 > 9", int64(0)},
{"5 >= 9", int64(0)},
{"9 >= 9", int64(1)},
{"9 < 5", int64(0)},
{"5 < 9", int64(1)},
{"9 <= 5", int64(0)},
{"5 <= 5", int64(1)},
{"1 and 2", int64(1)}, // Golang template definition: non-zero values are all truth
{"1 and 0", int64(0)},
{"0 and 0", int64(0)},
{"1 or 2", int64(1)},
{"1 or 0", int64(1)},
{"0 or 1", int64(1)},
{"0 or 0", int64(0)},
{"not 2 == 1", int64(1)},
{"not not ( 9 < 5 )", int64(0)},
}
for _, c := range cases {
n, err := Expr(tokens(c.expr)...)
if assert.NoError(t, err, "expr: %s", c.expr) {
assert.Equal(t, c.want, n.Value)
}
}
bads := []struct {
expr string
errMsg string
}{
{"0 / 0", "integer divide by zero"},
{"1 +", "num stack is empty"},
{"+ 1", "num stack is empty"},
{"( 1", "incomplete sub-expression"},
{"1 )", "op stack is empty"}, // can not find the corresponding open bracket after the stack becomes empty
{"1 , 2", "expect 1 value as final result"},
{"( 1 , 2 )", "too many values in one bracket"},
{"1 a 2", "unknown operator"},
}
for _, c := range bads {
_, err = Expr(tokens(c.expr)...)
assert.ErrorContains(t, err, c.errMsg, "expr: %s", c.expr)
}
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2014 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"html/template"
"net/url"
"strconv"
"strings"
"time"
"gitea.dev/modules/base"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup"
"gitea.dev/modules/public"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"gitea.dev/modules/templates/eval"
"gitea.dev/modules/util"
"gitea.dev/services/gitdiff"
)
func newFuncMapWebPage() template.FuncMap {
return map[string]any{
"DumpVar": dumpVar,
"NIL": func() any { return nil },
// -----------------------------------------------------------------
// html/template related functions
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
"Iif": iif,
"Eval": evalTokens,
"HTMLFormat": htmlFormat,
"QueryEscape": queryEscape,
"QueryBuild": QueryBuild,
"PathEscape": url.PathEscape,
"PathEscapeSegments": util.PathEscapeSegments,
// utils
"StringUtils": NewStringUtils,
"SliceUtils": NewSliceUtils,
"JsonUtils": NewJsonUtils,
"DateUtils": NewDateUtils,
// -----------------------------------------------------------------
// svg / avatar / icon / color
"svg": svg.RenderHTML,
"MigrationIcon": migrationIcon,
"ActionIcon": actionIcon,
"SortArrow": sortArrow,
"ContrastColor": util.ContrastColor,
// -----------------------------------------------------------------
// time / number / format
"ShortSha": base.ShortSha,
"FileSize": base.FileSize,
"CountFmt": countFmt,
"Sec2Hour": util.SecToHours,
"TimeEstimateString": timeEstimateString,
"LoadTimes": func(startTime time.Time) string {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
},
"AssetURI": public.AssetURI,
"AssetCSSLinks": public.AssetCSSLinks,
// -----------------------------------------------------------------
// setting
"AppName": func() string {
return setting.AppName
},
"AppSubUrl": func() string {
return setting.AppSubURL
},
"AssetUrlPrefix": func() string {
return setting.StaticURLPrefix + "/assets"
},
"AppVer": func() string {
return setting.AppVer
},
"AppDomain": func() string { // TODO: helm registry still uses it, need to use current request host in the future
return setting.Domain
},
"ShowFooterTemplateLoadTime": func() bool {
return setting.Other.ShowFooterTemplateLoadTime
},
"ShowFooterPoweredBy": func() bool {
return setting.Other.ShowFooterPoweredBy
},
"AllowedReactions": func() []string {
return setting.UI.Reactions
},
"CustomEmojis": func() map[string]string {
return setting.UI.CustomEmojisMap
},
"MetaAuthor": func() string {
return setting.UI.Meta.Author
},
"MetaDescription": func() string {
return setting.UI.Meta.Description
},
"MetaKeywords": func() string {
return setting.UI.Meta.Keywords
},
"EnableTimetracking": func() bool {
return setting.Service.EnableTimetracking
},
"DisableWebhooks": func() bool {
return setting.DisableWebhooks
},
"NotificationSettings": func() map[string]any {
return map[string]any{
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
}
},
"MermaidMaxSourceCharacters": func() int {
return setting.MermaidMaxSourceCharacters
},
// -----------------------------------------------------------------
// render
"RenderCodeBlock": renderCodeBlock,
"ReactionToEmoji": reactionToEmoji,
// -----------------------------------------------------------------
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
"ActionContent2Commits": ActionContent2Commits,
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
"MirrorRemoteAddress": mirrorRemoteAddress,
"FilenameIsImage": filenameIsImage,
"TabSizeClass": tabSizeClass,
}
}
func sanitizeHTML(msg string) template.HTML {
return markup.Sanitize(msg)
}
func htmlFormat(s any, args ...any) template.HTML {
if len(args) == 0 {
// to prevent developers from calling "HTMLFormat $userInput" by mistake which will lead to XSS
panic("missing arguments for HTMLFormat")
}
switch v := s.(type) {
case string:
return htmlutil.HTMLFormat(template.HTML(v), args...)
case template.HTML:
return htmlutil.HTMLFormat(v, args...)
}
panic(fmt.Sprintf("unexpected type %T", s))
}
func queryEscape(s string) template.URL {
return template.URL(url.QueryEscape(s))
}
// iif is an "inline-if", similar util.Iif[T] but templates need the non-generic version,
// and it could be simply used as "{{iif expr trueVal}}" (omit the falseVal).
func iif(condition any, vals ...any) any {
if isTemplateTruthy(condition) {
return vals[0]
} else if len(vals) > 1 {
return vals[1]
}
return nil
}
func isTemplateTruthy(v any) bool {
truth, _ := template.IsTrue(v)
return truth
}
// evalTokens evaluates the expression by tokens and returns the result, see the comment of eval.Expr for details.
// To use this helper function in templates, pass each token as a separate parameter.
//
// {{ $int64 := Eval $var "+" 1 }}
// {{ $float64 := Eval $var "+" 1.0 }}
//
// Golang's template supports comparable int types, so the int64 result can be used in later statements like {{if lt $int64 10}}
func evalTokens(tokens ...any) (any, error) {
n, err := eval.Expr(tokens...)
return n.Value, err
}
func isQueryParamEmpty(v any) bool {
return v == nil || v == false || v == 0 || v == int64(0) || v == ""
}
// QueryBuild builds a query string from a list of key-value pairs.
// It omits the nil, false, zero int/int64 and empty string values,
// because they are default empty values for "ctx.FormXxx" calls.
// If 0 or false need to be included, use string values: "0" and "false".
// Build rules:
// * Even parameters: always build as query string: a=b&c=d
// * Odd parameters:
// * * {"/anything", param-pairs...} => "/?param-paris"
// * * {"anything?old-params", new-param-pairs...} => "anything?old-params&new-param-paris"
// * * Otherwise: {"old&params", new-param-pairs...} => "old&params&new-param-paris"
// * * Other behaviors are undefined yet.
func QueryBuild(a ...any) template.URL {
var reqPath, s string
hasTrailingSep := false
if len(a)%2 == 1 {
if v, ok := a[0].(string); ok {
s = v
} else if v, ok := a[0].(template.URL); ok {
s = string(v)
} else {
panic("QueryBuild: invalid argument")
}
hasTrailingSep = s != "&" && strings.HasSuffix(s, "&")
if strings.HasPrefix(s, "/") || strings.Contains(s, "?") {
if s1, s2, ok := strings.Cut(s, "?"); ok {
reqPath = s1 + "?"
s = s2
} else {
reqPath += s + "?"
s = ""
}
}
}
for i := len(a) % 2; i < len(a); i += 2 {
k, ok := a[i].(string)
if !ok {
panic("QueryBuild: invalid argument")
}
var v string
if va, ok := a[i+1].(string); ok {
v = va
} else if a[i+1] != nil {
if !isQueryParamEmpty(a[i+1]) {
v = fmt.Sprint(a[i+1])
}
}
// pos1 to pos2 is the "k=v&" part, "&" is optional
pos1 := strings.Index(s, "&"+k+"=")
if pos1 != -1 {
pos1++
} else if strings.HasPrefix(s, k+"=") {
pos1 = 0
}
pos2 := len(s)
if pos1 == -1 {
pos1 = len(s)
} else {
pos2 = pos1 + 1
for pos2 < len(s) && s[pos2-1] != '&' {
pos2++
}
}
if v != "" {
sep := ""
hasPrefixSep := pos1 == 0 || (pos1 <= len(s) && s[pos1-1] == '&')
if !hasPrefixSep {
sep = "&"
}
s = s[:pos1] + sep + k + "=" + url.QueryEscape(v) + "&" + s[pos2:]
} else {
s = s[:pos1] + s[pos2:]
}
}
if s != "" && s[len(s)-1] == '&' && !hasTrailingSep {
s = s[:len(s)-1]
}
if reqPath != "" {
if s == "" {
s = reqPath
if s != "?" {
s = s[:len(s)-1]
}
} else {
if s[0] == '&' {
s = s[1:]
}
s = reqPath + s
}
}
return template.URL(s)
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"net/url"
"strings"
"testing"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestSubjectBodySeparator(t *testing.T) {
test := func(input, subject, body string) {
loc := mailSubjectSplit.FindStringIndex(input)
if loc == nil {
assert.Empty(t, subject, "no subject found, but one expected")
assert.Equal(t, body, input)
} else {
assert.Equal(t, subject, input[0:loc[0]])
assert.Equal(t, body, input[loc[1]:])
}
}
test("Simple\n---------------\nCase",
"Simple\n",
"\nCase")
test("Only\nBody",
"",
"Only\nBody")
test("Minimal\n---\nseparator",
"Minimal\n",
"\nseparator")
test("False --- separator",
"",
"False --- separator")
test("False\n--- separator",
"",
"False\n--- separator")
test("False ---\nseparator",
"",
"False ---\nseparator")
test("With extra spaces\n----- \t \nBody",
"With extra spaces\n",
"\nBody")
test("With leading spaces\n -------\nOnly body",
"",
"With leading spaces\n -------\nOnly body")
test("Multiple\n---\n-------\n---\nSeparators",
"Multiple\n",
"\n-------\n---\nSeparators")
test("Insufficient\n--\nSeparators",
"",
"Insufficient\n--\nSeparators")
}
func TestSanitizeHTML(t *testing.T) {
assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), sanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
}
func TestTemplateIif(t *testing.T) {
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"Iif": iif})
template.Must(tmpl.Parse(`{{if .Value}}true{{else}}false{{end}}:{{Iif .Value "true" "false"}}`))
cases := []any{nil, false, true, "", "string", 0, 1}
w := &strings.Builder{}
truthyCount := 0
for i, v := range cases {
w.Reset()
assert.NoError(t, tmpl.Execute(w, struct{ Value any }{v}), "case %d (%T) %#v fails", i, v, v)
out := w.String()
truthyCount += util.Iif(out == "true:true", 1, 0)
truthyMatches := out == "true:true" || out == "false:false"
assert.True(t, truthyMatches, "case %d (%T) %#v fail: %s", i, v, v, out)
}
assert.True(t, truthyCount != 0 && truthyCount != len(cases))
}
func TestTemplateEscape(t *testing.T) {
execTmpl := func(code string) string {
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"QueryBuild": QueryBuild, "HTMLFormat": htmlFormat})
template.Must(tmpl.Parse(code))
w := &strings.Builder{}
assert.NoError(t, tmpl.Execute(w, nil))
return w.String()
}
t.Run("Golang URL Escape", func(t *testing.T) {
// Golang template considers "href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
})
t.Run("Golang URL No-escape", func(t *testing.T) {
// non-URL content isn't auto-escaped
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
})
t.Run("QueryBuild", func(t *testing.T) {
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
actual = execTmpl(`<a href="?{{QueryBuild "a" "%"}}"></a>`)
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
})
t.Run("HTMLFormat", func(t *testing.T) {
actual := execTmpl("{{HTMLFormat `<a k=\"%s\">%s</a>` `\"` `<>`}}")
assert.Equal(t, `<a k="&#34;">&lt;&gt;</a>`, actual)
})
}
func TestQueryBuild(t *testing.T) {
t.Run("construct", func(t *testing.T) {
assert.Empty(t, string(QueryBuild()))
assert.Empty(t, string(QueryBuild("a", nil, "b", false, "c", 0, "d", "")))
assert.Equal(t, "a=1&b=true", string(QueryBuild("a", 1, "b", "true")))
// path with query parameters
assert.Equal(t, "/?k=1", string(QueryBuild("/", "k", 1)))
assert.Equal(t, "/", string(QueryBuild("/?k=a", "k", 0)))
// no path but question mark with query parameters
assert.Equal(t, "?k=1", string(QueryBuild("?", "k", 1)))
assert.Equal(t, "?", string(QueryBuild("?", "k", 0)))
assert.Equal(t, "path?k=1", string(QueryBuild("path?", "k", 1)))
assert.Equal(t, "path", string(QueryBuild("path?", "k", 0)))
// only query parameters
assert.Equal(t, "&k=1", string(QueryBuild("&", "k", 1)))
assert.Empty(t, string(QueryBuild("&", "k", 0)))
assert.Empty(t, string(QueryBuild("&k=a", "k", 0)))
assert.Empty(t, string(QueryBuild("k=a&", "k", 0)))
assert.Equal(t, "a=1&b=2", string(QueryBuild("a=1", "b", 2)))
assert.Equal(t, "&a=1&b=2", string(QueryBuild("&a=1", "b", 2)))
assert.Equal(t, "a=1&b=2&", string(QueryBuild("a=1&", "b", 2)))
})
t.Run("replace", func(t *testing.T) {
assert.Equal(t, "a=1&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", 1)))
assert.Equal(t, "a=b&c=1&e=f", string(QueryBuild("a=b&c=d&e=f", "c", 1)))
assert.Equal(t, "a=b&c=d&e=1", string(QueryBuild("a=b&c=d&e=f", "e", 1)))
assert.Equal(t, "a=b&c=d&e=f&k=1", string(QueryBuild("a=b&c=d&e=f", "k", 1)))
})
t.Run("replace-&", func(t *testing.T) {
assert.Equal(t, "&a=1&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", 1)))
assert.Equal(t, "&a=b&c=1&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", 1)))
assert.Equal(t, "&a=b&c=d&e=1", string(QueryBuild("&a=b&c=d&e=f", "e", 1)))
assert.Equal(t, "&a=b&c=d&e=f&k=1", string(QueryBuild("&a=b&c=d&e=f", "k", 1)))
})
t.Run("delete", func(t *testing.T) {
assert.Equal(t, "c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", "")))
assert.Equal(t, "a=b&e=f", string(QueryBuild("a=b&c=d&e=f", "c", "")))
assert.Equal(t, "a=b&c=d", string(QueryBuild("a=b&c=d&e=f", "e", "")))
assert.Equal(t, "a=b&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "k", "")))
})
t.Run("delete-&", func(t *testing.T) {
assert.Equal(t, "&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", "")))
assert.Equal(t, "&a=b&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", "")))
assert.Equal(t, "&a=b&c=d", string(QueryBuild("&a=b&c=d&e=f", "e", "")))
assert.Equal(t, "&a=b&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "k", "")))
})
}
const queryNonASCII = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // all non-letter & non-number chars
func TestQueryEscape(t *testing.T) {
// this test is a reference for "urlQueryEscape" in JS
// Special case for space encoding:
// * RFC 3986: Uniform Resource Identifier (URI): %20
// * WHATWG HTML: application/x-www-form-urlencoded: +
// * JavaScript: encodeURIComponent() uses "%20". URLSearchParams uses "+"
// * Golang: QueryEscape uses "+"
expected := "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
assert.Equal(t, expected, url.QueryEscape(queryNonASCII))
}
func TestPathEscape(t *testing.T) {
// this test is a reference for "pathEscape" in JS
expected := "%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~"
assert.Equal(t, expected, url.PathEscape(queryNonASCII))
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"bufio"
"bytes"
"errors"
"fmt"
"html/template"
"io"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
texttemplate "text/template"
"gitea.dev/modules/assetfs"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates/scopedtmpl"
"gitea.dev/modules/util"
)
type TemplateExecutor scopedtmpl.TemplateExecutor
type TplName string
type tmplRender struct {
templates atomic.Pointer[scopedtmpl.ScopedTemplate]
collectTemplateNames func() ([]string, error)
readTemplateContent func(name string) ([]byte, error)
}
func (h *tmplRender) Templates() *scopedtmpl.ScopedTemplate {
return h.templates.Load()
}
func (h *tmplRender) recompileTemplates(dummyFuncMap template.FuncMap) error {
tmpls := scopedtmpl.NewScopedTemplate()
tmpls.Funcs(dummyFuncMap)
names, err := h.collectTemplateNames()
if err != nil {
return err
}
for _, name := range names {
tmpl := tmpls.New(filepath.ToSlash(name))
buf, err := h.readTemplateContent(name)
if err != nil {
return err
}
if _, err = tmpl.Parse(string(buf)); err != nil {
return err
}
}
tmpls.Freeze()
h.templates.Store(tmpls)
return nil
}
func ReloadAllTemplates() error {
return errors.Join(PageRendererReload(), MailRendererReload())
}
func processStartupTemplateError(err error) {
if err == nil {
return
}
if setting.IsProd || setting.IsInTesting {
// in prod mode, Gitea must have correct templates to run
log.Fatal("Gitea can't run with template errors: %v", err)
}
// in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded
log.Error("There are template errors but Gitea continues to run in dev mode: %v", err)
}
type templateErrorPrettier struct {
assets *assetfs.LayeredFS
}
var reGenericTemplateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
func (p *templateErrorPrettier) handleGenericTemplateError(err error) string {
groups := reGenericTemplateError.FindStringSubmatch(err.Error())
if len(groups) != 4 {
return ""
}
tmplName, lineStr, message := groups[1], groups[2], groups[3]
return p.makeDetailedError(message, tmplName, lineStr, "", "")
}
var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`)
func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string {
groups := reFuncNotDefinedError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4]
funcName, _ = strconv.Unquote(`"` + funcName + `"`)
return p.makeDetailedError(message, tmplName, lineStr, "", funcName)
}
var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`)
func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string {
groups := reUnexpectedOperandError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
return p.makeDetailedError(message, tmplName, lineStr, "", unexpected)
}
var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`)
func (p *templateErrorPrettier) handleExpectedEndError(err error) string {
groups := reExpectedEndError.FindStringSubmatch(err.Error())
if len(groups) != 5 {
return ""
}
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
return p.makeDetailedError(message, tmplName, lineStr, "", unexpected)
}
var (
reTemplateExecutingError = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): (executing .*)`)
reTemplateExecutingErrorMsg = regexp.MustCompile(`^executing "(.*)" at <(.*)>: `)
)
func (p *templateErrorPrettier) handleTemplateRenderingError(err error) string {
if groups := reTemplateExecutingError.FindStringSubmatch(err.Error()); len(groups) > 0 {
tmplName, lineStr, posStr, msgPart := groups[1], groups[2], groups[3], groups[4]
target := ""
if groups = reTemplateExecutingErrorMsg.FindStringSubmatch(msgPart); len(groups) > 0 {
target = groups[2]
}
return p.makeDetailedError(msgPart, tmplName, lineStr, posStr, target)
} else if execErr, ok := err.(texttemplate.ExecError); ok {
layerName := p.assets.GetFileLayerName(execErr.Name + ".tmpl")
return fmt.Sprintf("asset from: %s, %s", layerName, err.Error())
}
return err.Error()
}
func HandleTemplateRenderingError(err error) string {
p := &templateErrorPrettier{assets: AssetFS()}
return p.handleTemplateRenderingError(err)
}
const dashSeparator = "----------------------------------------------------------------------"
func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName, lineNumStr, posNumStr, target string) string {
code, layer, err := p.assets.ReadLayeredFile(tmplName + ".tmpl")
if err != nil {
return fmt.Sprintf("template error: %s, and unable to find template file %q", errMsg, tmplName)
}
line, err := strconv.Atoi(lineNumStr)
if err != nil {
return fmt.Sprintf("template error: %s, unable to parse template %q line number %s", errMsg, tmplName, lineNumStr)
}
pos, err := strconv.Atoi(util.IfZero(posNumStr, "-1"))
if err != nil {
return fmt.Sprintf("template error: %s, unable to parse template %q pos number %s", errMsg, tmplName, posNumStr)
}
detail := extractErrorLine(code, line, pos, target)
var msg string
if pos >= 0 {
msg = fmt.Sprintf("template error: %s:%s:%d:%d : %s", layer, tmplName, line, pos, errMsg)
} else {
msg = fmt.Sprintf("template error: %s:%s:%d : %s", layer, tmplName, line, errMsg)
}
return msg + "\n" + dashSeparator + "\n" + detail + "\n" + dashSeparator
}
func extractErrorLine(code []byte, lineNum, posNum int, target string) string {
b := bufio.NewReader(bytes.NewReader(code))
var line []byte
var err error
for i := range lineNum {
if line, err = b.ReadBytes('\n'); err != nil {
if i == lineNum-1 && errors.Is(err, io.EOF) {
err = nil
}
break
}
}
if err != nil {
return fmt.Sprintf("unable to find target line %d", lineNum)
}
line = bytes.TrimRight(line, "\r\n")
var indicatorLine []byte
targetBytes := []byte(target)
targetLen := len(targetBytes)
for i := 0; i < len(line); {
if posNum == -1 && target != "" && bytes.HasPrefix(line[i:], targetBytes) {
for j := 0; j < targetLen && i < len(line); j++ {
indicatorLine = append(indicatorLine, '^')
i++
}
} else if i == posNum {
indicatorLine = append(indicatorLine, '^')
i++
} else {
if line[i] == '\t' {
indicatorLine = append(indicatorLine, '\t')
} else {
indicatorLine = append(indicatorLine, ' ')
}
i++
}
}
// if the indicatorLine only contains spaces, trim it together
return strings.TrimRight(string(line)+"\n"+string(indicatorLine), " \t\r\n")
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"errors"
"html/template"
"os"
"strings"
"testing"
"gitea.dev/modules/assetfs"
"github.com/stretchr/testify/assert"
)
func TestExtractErrorLine(t *testing.T) {
cases := []struct {
code string
line int
pos int
target string
expect string
}{
{"hello world\nfoo bar foo bar\ntest", 2, -1, "bar", `
foo bar foo bar
^^^ ^^^
`},
{"hello world\nfoo bar foo bar\ntest", 2, 4, "bar", `
foo bar foo bar
^
`},
{
"hello world\nfoo bar foo bar\ntest", 2, 4, "",
`
foo bar foo bar
^
`,
},
{
"hello world\nfoo bar foo bar\ntest", 5, 0, "",
`unable to find target line 5`,
},
}
for _, c := range cases {
actual := extractErrorLine([]byte(c.code), c.line, c.pos, c.target)
assert.Equal(t, strings.TrimSpace(c.expect), strings.TrimSpace(actual))
}
}
func TestHandleError(t *testing.T) {
dir := t.TempDir()
p := &templateErrorPrettier{assets: assetfs.Layered(assetfs.Local("tmp", dir))}
test := func(s string, h func(error) string, expect string) {
err := os.WriteFile(dir+"/test.tmpl", []byte(s), 0o644)
assert.NoError(t, err)
tmpl := template.New("test")
_, err = tmpl.Parse(s)
assert.Error(t, err)
msg := h(err)
assert.Equal(t, strings.TrimSpace(expect), strings.TrimSpace(msg))
}
test("{{", p.handleGenericTemplateError, `
template error: tmp:test:1 : unclosed action
----------------------------------------------------------------------
{{
----------------------------------------------------------------------
`)
test("{{Func}}", p.handleFuncNotDefinedError, `
template error: tmp:test:1 : function "Func" not defined
----------------------------------------------------------------------
{{Func}}
^^^^
----------------------------------------------------------------------
`)
test("{{'x'3}}", p.handleUnexpectedOperandError, `
template error: tmp:test:1 : unexpected "3" in operand
----------------------------------------------------------------------
{{'x'3}}
^
----------------------------------------------------------------------
`)
// no idea about how to trigger such strange error, so mock an error to test it
err := os.WriteFile(dir+"/test.tmpl", []byte("god knows XXX"), 0o644)
assert.NoError(t, err)
expectedMsg := `
template error: tmp:test:1 : expected end; found XXX
----------------------------------------------------------------------
god knows XXX
^^^
----------------------------------------------------------------------
`
actualMsg := p.handleExpectedEndError(errors.New("template: test:1: expected end; found XXX"))
assert.Equal(t, strings.TrimSpace(expectedMsg), strings.TrimSpace(actualMsg))
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"io"
"net/url"
"regexp"
"slices"
"strings"
"sync"
texttmpl "text/template"
"gitea.dev/modules/base"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type MailRender struct {
TemplateNames []string
BodyTemplates struct {
HasTemplate func(name string) bool
ExecuteTemplate func(w io.Writer, name string, data any) error
}
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
// It is an incomplete implementation from "Use templates for issue e-mail subject and body" https://github.com/go-gitea/gitea/pull/8329
SubjectTemplates *texttmpl.Template
tmplRenderer *tmplRender
mockedBodyTemplates map[string]*template.Template
}
// dotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent auto-linkers from detecting these as urls
func dotEscape(raw string) string {
return strings.ReplaceAll(raw, ".", "\u200d.\u200d")
}
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
func mailSubjectTextFuncMap() texttmpl.FuncMap {
return texttmpl.FuncMap{
"dict": dict,
"Eval": evalTokens,
"EllipsisString": util.EllipsisDisplayString,
"AppName": func() string {
return setting.AppName
},
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
}
}
func mailBodyFuncMap() template.FuncMap {
// Some of them are documented in mail-templates.md
return template.FuncMap{
"DumpVar": dumpVar,
"NIL": func() any { return nil },
// html/template related functions
"dict": dict,
"Iif": iif,
"Eval": evalTokens,
"HTMLFormat": htmlFormat,
"QueryEscape": queryEscape,
"QueryBuild": QueryBuild,
// deprecated, use "HTMLFormat" instead, but some user custom mail templates still use it
// see: https://github.com/go-gitea/gitea/issues/36049
"SanitizeHTML": sanitizeHTML,
"PathEscape": url.PathEscape,
"PathEscapeSegments": util.PathEscapeSegments,
"DotEscape": dotEscape,
// utils
"StringUtils": NewStringUtils,
"SliceUtils": NewSliceUtils,
"JsonUtils": NewJsonUtils,
// time / number / format
"ShortSha": base.ShortSha,
"FileSize": base.FileSize,
// setting
"AppName": func() string {
return setting.AppName
},
"AppUrl": func() string {
return setting.AppURL
},
"AppDomain": func() string {
return setting.Domain
},
}
}
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
func newMailRenderer() (*MailRender, error) {
subjectTemplates := texttmpl.New("")
subjectTemplates.Funcs(mailSubjectTextFuncMap())
renderer := &MailRender{
SubjectTemplates: subjectTemplates,
}
assetFS := AssetFS()
renderer.tmplRenderer = &tmplRender{
collectTemplateNames: func() ([]string, error) {
names, err := assetFS.ListAllFiles(".", true)
if err != nil {
return nil, err
}
names = slices.DeleteFunc(names, func(file string) bool {
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
})
for i, name := range names {
names[i] = strings.TrimSuffix(strings.TrimPrefix(name, "mail/"), ".tmpl")
}
renderer.TemplateNames = names
return names, nil
},
readTemplateContent: func(name string) ([]byte, error) {
content, err := assetFS.ReadFile("mail/" + name + ".tmpl")
if err != nil {
return nil, err
}
var subjectContent []byte
bodyContent := content
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
subjectContent, bodyContent = content[0:loc[0]], content[loc[1]:]
}
_, err = renderer.SubjectTemplates.New(name).Parse(string(subjectContent))
if err != nil {
return nil, err
}
return bodyContent, nil
},
}
renderer.BodyTemplates.HasTemplate = func(name string) bool {
if renderer.mockedBodyTemplates[name] != nil {
return true
}
return renderer.tmplRenderer.Templates().HasTemplate(name)
}
staticFuncMap := mailBodyFuncMap()
renderer.BodyTemplates.ExecuteTemplate = func(w io.Writer, name string, data any) error {
if t, ok := renderer.mockedBodyTemplates[name]; ok {
return t.Execute(w, data)
}
t, err := renderer.tmplRenderer.Templates().Executor(name, staticFuncMap)
if err != nil {
return err
}
return t.Execute(w, data)
}
err := renderer.tmplRenderer.recompileTemplates(staticFuncMap)
if err != nil {
return nil, err
}
return renderer, nil
}
func (r *MailRender) MockTemplate(name, subject, body string) func() {
if r.mockedBodyTemplates == nil {
r.mockedBodyTemplates = make(map[string]*template.Template)
}
oldSubject := r.SubjectTemplates
r.SubjectTemplates, _ = r.SubjectTemplates.Clone()
texttmpl.Must(r.SubjectTemplates.New(name).Parse(subject))
oldBody, hasOldBody := r.mockedBodyTemplates[name]
mockFuncMap := mailBodyFuncMap()
r.mockedBodyTemplates[name] = template.Must(template.New(name).Funcs(mockFuncMap).Parse(body))
return func() {
r.SubjectTemplates = oldSubject
if hasOldBody {
r.mockedBodyTemplates[name] = oldBody
} else {
delete(r.mockedBodyTemplates, name)
}
}
}
var (
globalMailRenderer *MailRender
globalMailRendererMu sync.RWMutex
)
func MailRendererReload() error {
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
r, err := newMailRenderer()
if err != nil {
return err
}
globalMailRenderer = r
return nil
}
func MailRenderer() *MailRender {
globalMailRendererMu.RLock()
r := globalMailRenderer
globalMailRendererMu.RUnlock()
if r != nil {
return r
}
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
if globalMailRenderer != nil {
return globalMailRenderer
}
var err error
globalMailRenderer, err = newMailRenderer()
if err != nil {
log.Fatal("Failed to initialize mail renderer: %v", err)
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
globalMailRendererMu.Lock()
defer globalMailRendererMu.Unlock()
r, err := newMailRenderer()
if err != nil {
log.Error("Mail template error: %v", err)
return
}
globalMailRenderer = r
})
}
return globalMailRenderer
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"fmt"
"html/template"
"io"
"net/http"
"slices"
"strings"
"sync"
texttemplate "text/template"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type pageRenderer struct {
tmplRenderer *tmplRender
}
func (r *pageRenderer) funcMap(ctx context.Context) template.FuncMap {
pageFuncMap := newFuncMapWebPage()
pageFuncMap["ctx"] = func() any { return ctx }
return pageFuncMap
}
func (r *pageRenderer) funcMapDummy() template.FuncMap {
dummyFuncMap := newFuncMapWebPage()
dummyFuncMap["ctx"] = func() any { return nil } // for template compilation only, no context available
return dummyFuncMap
}
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
tmpls := r.tmplRenderer.Templates()
if tmpls == nil {
return nil, fmt.Errorf("no templates defined for %s", tmpl)
}
return tmpls.Executor(tmpl, r.funcMap(templateCtx))
}
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
name := string(tplName)
if respWriter, ok := w.(http.ResponseWriter); ok {
if respWriter.Header().Get("Content-Type") == "" {
respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
}
respWriter.WriteHeader(status)
}
t, err := r.TemplateLookup(name, templateCtx)
if err != nil {
return texttemplate.ExecError{Name: name, Err: err}
}
return t.Execute(w, data)
}
var PageRenderer = sync.OnceValue(func() *pageRenderer {
rendererType := util.Iif(setting.IsProd, "static", "auto-reloading")
log.Debug("Creating %s HTML Renderer", rendererType)
assetFS := AssetFS()
tr := &tmplRender{
collectTemplateNames: func() ([]string, error) {
names, err := assetFS.ListAllFiles(".", true)
if err != nil {
return nil, err
}
names = slices.DeleteFunc(names, func(file string) bool {
return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
})
for i, file := range names {
names[i] = strings.TrimSuffix(file, ".tmpl")
}
return names, nil
},
readTemplateContent: func(name string) ([]byte, error) {
return assetFS.ReadFile(name + ".tmpl")
},
}
pr := &pageRenderer{tmplRenderer: tr}
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
processStartupTemplateError(err)
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
log.Error("Template error: %v\n%s", err, log.Stack(2))
}
})
}
return pr
})
func PageRendererReload() error {
return PageRenderer().tmplRenderer.recompileTemplates(PageRenderer().funcMapDummy())
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package scopedtmpl
import (
"fmt"
"html/template"
"io"
"maps"
"reflect"
"sync"
texttemplate "text/template"
"text/template/parse"
"unsafe"
)
type TemplateExecutor interface {
Execute(wr io.Writer, data any) error
}
type ScopedTemplate struct {
all *template.Template
parseFuncs template.FuncMap // this func map is only used for parsing templates
frozen bool
scopedMu sync.RWMutex
scopedTemplateSets map[string]*scopedTemplateSet
}
func NewScopedTemplate() *ScopedTemplate {
return &ScopedTemplate{
all: template.New(""),
parseFuncs: template.FuncMap{},
scopedTemplateSets: map[string]*scopedTemplateSet{},
}
}
func (t *ScopedTemplate) Funcs(funcMap template.FuncMap) {
if t.frozen {
panic("cannot add new functions to frozen template set")
}
t.all.Funcs(funcMap)
maps.Copy(t.parseFuncs, funcMap)
}
func (t *ScopedTemplate) New(name string) *template.Template {
if t.frozen {
panic("cannot add new template to frozen template set")
}
return t.all.New(name)
}
func (t *ScopedTemplate) Freeze() {
t.frozen = true
// reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping
m := template.FuncMap{}
for k := range t.parseFuncs {
m[k] = func(v ...any) any { return nil }
}
t.all.Funcs(m)
}
func (t *ScopedTemplate) HasTemplate(name string) bool {
return t.all.Lookup(name) != nil
}
func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
t.scopedMu.RLock()
scopedTmplSet, ok := t.scopedTemplateSets[name]
t.scopedMu.RUnlock()
if !ok {
var err error
t.scopedMu.Lock()
if scopedTmplSet, ok = t.scopedTemplateSets[name]; !ok {
if scopedTmplSet, err = newScopedTemplateSet(t.all, name); err == nil {
t.scopedTemplateSets[name] = scopedTmplSet
}
}
t.scopedMu.Unlock()
if err != nil {
return nil, err
}
}
if scopedTmplSet == nil {
return nil, fmt.Errorf("template %s not found", name)
}
return scopedTmplSet.newExecutor(funcMap), nil
}
type scopedTemplateSet struct {
name string
htmlTemplates map[string]*template.Template
textTemplates map[string]*texttemplate.Template
execFuncs map[string]reflect.Value
}
func escapeTemplate(t *template.Template) error {
// force the Golang HTML template to complete the escaping work
err := t.Execute(io.Discard, nil)
if _, ok := err.(*template.Error); ok {
return err
}
return nil
}
type htmlTemplate struct {
_/*escapeErr*/ error
text *texttemplate.Template
}
type textTemplateCommon struct {
_/*tmpl*/ map[string]*template.Template
_/*muTmpl*/ sync.RWMutex
_/*option*/ struct {
missingKey int
}
muFuncs sync.RWMutex
_/*parseFuncs*/ texttemplate.FuncMap
execFuncs map[string]reflect.Value
}
type textTemplate struct {
_/*name*/ string
*parse.Tree
*textTemplateCommon
_/*leftDelim*/ string
_/*rightDelim*/ string
}
func ptr[T, P any](ptr *P) *T {
// https://pkg.go.dev/unsafe#Pointer
// (1) Conversion of a *T1 to Pointer to *T2.
// Provided that T2 is no larger than T1 and that the two share an equivalent memory layout,
// this conversion allows reinterpreting data of one type as data of another type.
return (*T)(unsafe.Pointer(ptr))
}
func newScopedTemplateSet(all *template.Template, name string) (*scopedTemplateSet, error) {
targetTmpl := all.Lookup(name)
if targetTmpl == nil {
return nil, fmt.Errorf("template %q not found", name)
}
if err := escapeTemplate(targetTmpl); err != nil {
return nil, fmt.Errorf("template %q has an error when escaping: %w", name, err)
}
ts := &scopedTemplateSet{
name: name,
htmlTemplates: map[string]*template.Template{},
textTemplates: map[string]*texttemplate.Template{},
}
htmlTmpl := ptr[htmlTemplate](all)
textTmpl := htmlTmpl.text
textTmplPtr := ptr[textTemplate](textTmpl)
textTmplPtr.muFuncs.Lock()
ts.execFuncs = map[string]reflect.Value{}
maps.Copy(ts.execFuncs, textTmplPtr.execFuncs)
textTmplPtr.muFuncs.Unlock()
var collectTemplates func(nodes []parse.Node)
var collectErr error // only need to collect the one error
collectTemplates = func(nodes []parse.Node) {
for _, node := range nodes {
if node.Type() == parse.NodeTemplate {
nodeTemplate := node.(*parse.TemplateNode)
subName := nodeTemplate.Name
if ts.htmlTemplates[subName] == nil {
subTmpl := all.Lookup(subName)
if subTmpl == nil {
// HTML template will add some internal templates like "$delimDoubleQuote" into the text template
ts.textTemplates[subName] = textTmpl.Lookup(subName)
} else if subTmpl.Tree == nil || subTmpl.Tree.Root == nil {
collectErr = fmt.Errorf("template %q has no tree, it's usually caused by broken templates", subName)
} else {
ts.htmlTemplates[subName] = subTmpl
if err := escapeTemplate(subTmpl); err != nil {
collectErr = fmt.Errorf("template %q has an error when escaping: %w", subName, err)
return
}
collectTemplates(subTmpl.Tree.Root.Nodes)
}
}
} else if node.Type() == parse.NodeList {
nodeList := node.(*parse.ListNode)
collectTemplates(nodeList.Nodes)
} else if node.Type() == parse.NodeIf {
nodeIf := node.(*parse.IfNode)
collectTemplates(nodeIf.BranchNode.List.Nodes)
if nodeIf.BranchNode.ElseList != nil {
collectTemplates(nodeIf.BranchNode.ElseList.Nodes)
}
} else if node.Type() == parse.NodeRange {
nodeRange := node.(*parse.RangeNode)
collectTemplates(nodeRange.BranchNode.List.Nodes)
if nodeRange.BranchNode.ElseList != nil {
collectTemplates(nodeRange.BranchNode.ElseList.Nodes)
}
} else if node.Type() == parse.NodeWith {
nodeWith := node.(*parse.WithNode)
collectTemplates(nodeWith.BranchNode.List.Nodes)
if nodeWith.BranchNode.ElseList != nil {
collectTemplates(nodeWith.BranchNode.ElseList.Nodes)
}
}
}
}
ts.htmlTemplates[name] = targetTmpl
collectTemplates(targetTmpl.Tree.Root.Nodes)
return ts, collectErr
}
func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor {
tmpl := texttemplate.New("")
tmplPtr := ptr[textTemplate](tmpl)
tmplPtr.execFuncs = map[string]reflect.Value{}
maps.Copy(tmplPtr.execFuncs, ts.execFuncs)
if funcMap != nil {
tmpl.Funcs(funcMap)
}
// after escapeTemplate, the html templates are also escaped text templates, so it could be added to the text template directly
for _, t := range ts.htmlTemplates {
_, _ = tmpl.AddParseTree(t.Name(), t.Tree)
}
for _, t := range ts.textTemplates {
_, _ = tmpl.AddParseTree(t.Name(), t.Tree)
}
// now the text template has all necessary escaped templates, so we can safely execute, just like what the html template does
return tmpl.Lookup(ts.name)
}
@@ -0,0 +1,95 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package scopedtmpl
import (
"bytes"
"html/template"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestScopedTemplateSetFuncMap(t *testing.T) {
all := template.New("")
all.Funcs(template.FuncMap{"CtxFunc": func(s string) string {
return "default"
}})
_, err := all.New("base").Parse(`{{CtxFunc "base"}}`)
assert.NoError(t, err)
_, err = all.New("test").Parse(strings.TrimSpace(`
{{template "base"}}
{{CtxFunc "test"}}
{{template "base"}}
{{CtxFunc "test"}}
`))
assert.NoError(t, err)
ts, err := newScopedTemplateSet(all, "test")
assert.NoError(t, err)
// try to use different CtxFunc to render concurrently
funcMap1 := template.FuncMap{
"CtxFunc": func(s string) string {
time.Sleep(100 * time.Millisecond)
return s + "1"
},
}
funcMap2 := template.FuncMap{
"CtxFunc": func(s string) string {
time.Sleep(100 * time.Millisecond)
return s + "2"
},
}
out1 := bytes.Buffer{}
out2 := bytes.Buffer{}
wg := sync.WaitGroup{}
wg.Go(func() {
err := ts.newExecutor(funcMap1).Execute(&out1, nil)
assert.NoError(t, err)
})
wg.Go(func() {
err := ts.newExecutor(funcMap2).Execute(&out2, nil)
assert.NoError(t, err)
})
wg.Wait()
assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String())
assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String())
}
func TestScopedTemplateSetEscape(t *testing.T) {
all := template.New("")
_, err := all.New("base").Parse(`<a href="?q={{.param}}">{{.text}}</a>`)
assert.NoError(t, err)
_, err = all.New("test").Parse(`{{template "base" .}}<form action="?q={{.param}}">{{.text}}</form>`)
assert.NoError(t, err)
ts, err := newScopedTemplateSet(all, "test")
assert.NoError(t, err)
out := bytes.Buffer{}
err = ts.newExecutor(nil).Execute(&out, map[string]string{"param": "/", "text": "<"})
assert.NoError(t, err)
assert.Equal(t, `<a href="?q=%2f">&lt;</a><form action="?q=%2f">&lt;</form>`, out.String())
}
func TestScopedTemplateSetUnsafe(t *testing.T) {
all := template.New("")
_, err := all.New("test").Parse(`<a href="{{if true}}?{{end}}a={{.param}}"></a>`)
assert.NoError(t, err)
_, err = newScopedTemplateSet(all, "test")
assert.ErrorContains(t, err, "appears in an ambiguous context within a URL")
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2016 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build bindata
//go:generate go run ../../build/generate-bindata.go ../../templates bindata.dat
package templates
import (
"sync"
"gitea.dev/modules/assetfs"
_ "embed"
)
//go:embed bindata.dat
var bindata []byte
var BuiltinAssets = sync.OnceValue(func() *assetfs.Layer {
return assetfs.Bindata("builtin(bindata)", assetfs.NewEmbeddedFS(bindata))
})
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2016 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !bindata
package templates
import (
"gitea.dev/modules/assetfs"
"gitea.dev/modules/setting"
)
func BuiltinAssets() *assetfs.Layer {
return assetfs.Local("builtin(static)", setting.StaticRootPath, "templates")
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
git_model "gitea.dev/models/git"
actions_module "gitea.dev/modules/actions"
)
type ActionsUtils struct {
ctx context.Context
}
func NewActionsUtils(ctx context.Context) *ActionsUtils {
return &ActionsUtils{ctx: ctx}
}
func (a *ActionsUtils) CommitStatusesToActionsStatuses(statuses []*git_model.CommitStatus) actions_module.CommitActionsStatusMap {
return actions_module.GetCommitActionsStatusMap(a.ctx, statuses)
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"html"
"html/template"
"strconv"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/avatars"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
gitea_html "gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type AvatarUtils struct {
ctx context.Context
}
func NewAvatarUtils(ctx context.Context) *AvatarUtils {
return &AvatarUtils{ctx: ctx}
}
// AvatarHTML creates the HTML for an avatar
func AvatarHTML(src string, size int, class, name string) template.HTML {
sizeStr := strconv.Itoa(size)
name = util.IfZero(name, "avatar")
// use empty alt, otherwise if the image fails to load, the width will follow the "alt" text's width
return template.HTML(`<img loading="lazy" alt class="` + html.EscapeString(class) + `" src="` + html.EscapeString(src) + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `">`)
}
// Avatar renders user avatars. args: user, size (int), class (string)
func (au *AvatarUtils) Avatar(item any, others ...any) template.HTML {
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
switch t := item.(type) {
case *user_model.User:
src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
if src != "" {
return AvatarHTML(src, size, class, t.DisplayName())
}
case *repo_model.Collaborator:
src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
if src != "" {
return AvatarHTML(src, size, class, t.DisplayName())
}
case *organization.Organization:
src := t.AsUser().AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
if src != "" {
return AvatarHTML(src, size, class, t.AsUser().DisplayName())
}
}
return AvatarHTML(avatars.DefaultAvatarLink(), size, class, "")
}
// AvatarByAction renders user avatars from action. args: action, size (int), class (string)
func (au *AvatarUtils) AvatarByAction(action *activities_model.Action, others ...any) template.HTML {
action.LoadActUser(au.ctx)
return au.Avatar(action.ActUser, others...)
}
// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string)
func (au *AvatarUtils) AvatarByEmail(email, name string, others ...any) template.HTML {
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
src := avatars.GenerateEmailAvatarFastLink(au.ctx, email, size*setting.Avatar.RenderedSizeFactor)
if src != "" {
return AvatarHTML(src, size, class, name)
}
return ""
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"html"
"html/template"
"strings"
"time"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
type DateUtils struct{}
func NewDateUtils() *DateUtils {
return (*DateUtils)(nil) // the util is stateless, and we do not need to create an instance
}
// AbsoluteShort renders in "Jan 01, 2006" format
func (du *DateUtils) AbsoluteShort(time any) template.HTML {
return dateTimeFormat("short", time)
}
// AbsoluteLong renders in "January 01, 2006" format
func (du *DateUtils) AbsoluteLong(time any) template.HTML {
return dateTimeFormat("long", time)
}
// FullTime renders in "Jan 01, 2006 20:33:44" format
func (du *DateUtils) FullTime(time any) template.HTML {
return dateTimeFormat("full", time)
}
func (du *DateUtils) TimeSince(time any) template.HTML {
return TimeSince(time)
}
// ParseLegacy parses the datetime in legacy format, eg: "2016-01-02" in server's timezone.
// It shouldn't be used in new code. New code should use Time or TimeStamp as much as possible.
func (du *DateUtils) ParseLegacy(datetime string) time.Time {
return parseLegacy(datetime)
}
func parseLegacy(datetime string) time.Time {
t, err := time.Parse(time.RFC3339, datetime)
if err != nil {
t, _ = time.ParseInLocation(time.DateOnly, datetime, setting.DefaultUILocation)
}
return t
}
func anyToTime(value any) (t time.Time, isZero bool) {
switch v := value.(type) {
case nil:
// it is zero
case *time.Time:
if v != nil {
t = *v
}
case time.Time:
t = v
case timeutil.TimeStamp:
t = v.AsTime()
case timeutil.TimeStampNano:
t = v.AsTime()
case int:
t = timeutil.TimeStamp(v).AsTime()
case int64:
t = timeutil.TimeStamp(v).AsTime()
default:
panic(fmt.Sprintf("Unsupported time type %T", value))
}
return t, t.IsZero() || t.Unix() == 0
}
func dateTimeFormat(format string, datetime any) template.HTML {
t, isZero := anyToTime(datetime)
if isZero {
return "-"
}
var textEscaped string
datetimeEscaped := html.EscapeString(t.Format(time.RFC3339))
if format == "full" {
textEscaped = html.EscapeString(t.Format("2006-01-02 15:04:05 -07:00"))
} else {
textEscaped = html.EscapeString(t.Format("2006-01-02"))
}
attrs := []string{`weekday=""`, `year="numeric"`}
switch format {
case "short", "long": // date only
attrs = append(attrs, `threshold="P0Y"`, `month="`+format+`"`, `day="numeric"`, `prefix=""`)
case "full": // full date including time
attrs = append(attrs, `format="datetime"`, `month="short"`, `day="numeric"`, `hour="numeric"`, `minute="numeric"`, `second="numeric"`, `data-tooltip-content`, `data-tooltip-interactive="true"`)
default:
panic("Unsupported format " + format)
}
return template.HTML(fmt.Sprintf(`<relative-time %s datetime="%s">%s</relative-time>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
}
func timeSinceTo(then any, now time.Time) template.HTML {
thenTime, isZero := anyToTime(then)
if isZero {
return "-"
}
friendlyText := thenTime.Format("2006-01-02 15:04:05 -07:00")
// document: https://github.com/github/relative-time-element
attrs := `tense="past"`
isFuture := now.Before(thenTime)
if isFuture {
attrs = `tense="future"`
}
// declare data-tooltip-content attribute to switch from "title" tooltip to "tippy" tooltip
htm := fmt.Sprintf(`<relative-time prefix="" %s datetime="%s" data-tooltip-content data-tooltip-interactive="true">%s</relative-time>`,
attrs, thenTime.Format(time.RFC3339), friendlyText)
return template.HTML(htm)
}
// TimeSince renders relative time HTML given a time
func TimeSince(then any) template.HTML {
if setting.UI.PreferredTimestampTense == "absolute" {
return dateTimeFormat("full", then)
}
return timeSinceTo(then, time.Now())
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"testing"
"time"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestDateTime(t *testing.T) {
testTz, _ := time.LoadLocation("America/New_York")
defer test.MockVariableValue(&setting.DefaultUILocation, testTz)()
defer test.MockVariableValue(&setting.IsProd, true)()
defer test.MockVariableValue(&setting.IsInTesting, false)()
du := NewDateUtils()
refTimeStr := "2018-01-01T00:00:00Z"
refTime, _ := time.Parse(time.RFC3339, refTimeStr)
refTimeStamp := timeutil.TimeStamp(refTime.Unix())
assert.EqualValues(t, "-", du.AbsoluteShort(nil))
assert.EqualValues(t, "-", du.AbsoluteShort(0))
assert.EqualValues(t, "-", du.AbsoluteShort(time.Time{}))
assert.EqualValues(t, "-", du.AbsoluteShort(timeutil.TimeStamp(0)))
actual := du.AbsoluteShort(refTime)
assert.EqualValues(t, `<relative-time weekday="" year="numeric" threshold="P0Y" month="short" day="numeric" prefix="" datetime="2018-01-01T00:00:00Z">2018-01-01</relative-time>`, actual)
actual = du.AbsoluteShort(refTimeStamp)
assert.EqualValues(t, `<relative-time weekday="" year="numeric" threshold="P0Y" month="short" day="numeric" prefix="" datetime="2017-12-31T19:00:00-05:00">2017-12-31</relative-time>`, actual)
actual = du.FullTime(refTimeStamp)
assert.EqualValues(t, `<relative-time weekday="" year="numeric" format="datetime" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" data-tooltip-content data-tooltip-interactive="true" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
}
func TestTimeSince(t *testing.T) {
testTz, _ := time.LoadLocation("America/New_York")
defer test.MockVariableValue(&setting.DefaultUILocation, testTz)()
defer test.MockVariableValue(&setting.IsProd, true)()
defer test.MockVariableValue(&setting.IsInTesting, false)()
du := NewDateUtils()
assert.EqualValues(t, "-", du.TimeSince(nil))
refTimeStr := "2018-01-01T00:00:00Z"
refTime, _ := time.Parse(time.RFC3339, refTimeStr)
actual := du.TimeSince(refTime)
assert.EqualValues(t, `<relative-time prefix="" tense="past" datetime="2018-01-01T00:00:00Z" data-tooltip-content data-tooltip-interactive="true">2018-01-01 00:00:00 +00:00</relative-time>`, actual)
actual = timeSinceTo(&refTime, time.Time{})
assert.EqualValues(t, `<relative-time prefix="" tense="future" datetime="2018-01-01T00:00:00Z" data-tooltip-content data-tooltip-interactive="true">2018-01-01 00:00:00 +00:00</relative-time>`, actual)
actual = du.TimeSince(timeutil.TimeStampNano(refTime.UnixNano()))
assert.EqualValues(t, `<relative-time prefix="" tense="past" datetime="2017-12-31T19:00:00-05:00" data-tooltip-content data-tooltip-interactive="true">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"errors"
"fmt"
"html"
"html/template"
"reflect"
"gitea.dev/modules/container"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
)
func dictMerge(base map[string]any, arg any) bool {
if arg == nil {
return true
}
rv := reflect.ValueOf(arg)
if rv.Kind() == reflect.Map {
for _, k := range rv.MapKeys() {
base[k.String()] = rv.MapIndex(k).Interface()
}
return true
}
return false
}
// dict is a helper function for creating a map[string]any from a list of key-value pairs.
// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
func dict(args ...any) (map[string]any, error) {
if len(args)%2 != 0 {
return nil, errors.New("invalid dict constructor syntax: must have key-value pairs")
}
m := make(map[string]any, len(args)/2)
for i := 0; i < len(args); i += 2 {
key, ok := args[i].(string)
if !ok {
return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
}
if key == "." {
if ok = dictMerge(m, args[i+1]); !ok {
return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
}
} else {
m[key] = args[i+1]
}
}
return m, nil
}
func dumpVarMarshalable(v any, dumped container.Set[uintptr]) (ret any, ok bool) {
if v == nil {
return nil, true
}
e := reflect.ValueOf(v)
for e.Kind() == reflect.Pointer {
e = e.Elem()
}
if e.CanAddr() {
addr := e.UnsafeAddr()
if !dumped.Add(addr) {
return "[dumped]", false
}
defer dumped.Remove(addr)
}
switch e.Kind() {
case reflect.Bool, reflect.String,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return e.Interface(), true
case reflect.Struct:
m := map[string]any{}
for i := 0; i < e.NumField(); i++ {
k := e.Type().Field(i).Name
if !e.Type().Field(i).IsExported() {
continue
}
v := e.Field(i).Interface()
m[k], _ = dumpVarMarshalable(v, dumped)
}
return m, true
case reflect.Map:
m := map[string]any{}
for _, k := range e.MapKeys() {
m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped)
}
return m, true
case reflect.Array, reflect.Slice:
var m []any
for i := 0; i < e.Len(); i++ {
v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped)
m = append(m, v)
}
return m, true
default:
return "[" + reflect.TypeOf(v).String() + "]", false
}
}
// dumpVar helps to dump a variable in a template, to help debugging and development.
func dumpVar(v any) template.HTML {
if setting.IsProd {
return "<pre>dumpVar: only available in dev mode</pre>"
}
m, ok := dumpVarMarshalable(v, make(container.Set[uintptr]))
var dumpStr string
jsonBytes, err := json.MarshalIndent(m, "", " ")
if err != nil {
dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err)
} else if ok {
dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes))
} else {
dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes))
}
return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>")
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"strconv"
"gitea.dev/modules/util"
)
func timeEstimateString(timeSec any) string {
v, _ := util.ToInt64(timeSec)
if v == 0 {
return ""
}
return util.TimeEstimateString(v)
}
func countFmt(data any) string {
// legacy code, not ideal, still used in some places
num, err := util.ToInt64(data)
if err != nil {
return ""
}
if num < 1000 {
return strconv.FormatInt(num, 10)
} else if num < 1_000_000 {
num2 := float32(num) / 1000.0
return fmt.Sprintf("%.1fk", num2)
} else if num < 1_000_000_000 {
num2 := float32(num) / 1_000_000.0
return fmt.Sprintf("%.1fM", num2)
}
num2 := float32(num) / 1_000_000_000.0
return fmt.Sprintf("%.1fG", num2)
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCountFmt(t *testing.T) {
assert.Equal(t, "125", countFmt(125))
assert.Equal(t, "1.3k", countFmt(int64(1317)))
assert.Equal(t, "21.3M", countFmt(21317675))
assert.Equal(t, "45.7G", countFmt(int64(45721317675)))
assert.Empty(t, countFmt("test"))
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"bytes"
"gitea.dev/modules/json"
)
type JsonUtils struct{} //nolint:revive // variable naming triggers on Json, wants JSON
var jsonUtils = JsonUtils{}
func NewJsonUtils() *JsonUtils { //nolint:revive // variable naming triggers on Json, wants JSON
return &jsonUtils
}
func (su *JsonUtils) EncodeToString(v any) string {
out, err := json.Marshal(v)
if err != nil {
return ""
}
return string(out)
}
func (su *JsonUtils) PrettyIndent(s string) string {
var out bytes.Buffer
err := json.Indent(&out, []byte(s), "", " ")
if err != nil {
return ""
}
return out.String()
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"html/template"
"mime"
"path/filepath"
"strconv"
"strings"
"time"
activities_model "gitea.dev/models/activities"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"github.com/editorconfig/editorconfig-core-go/v2"
)
func sortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML {
// if needed
if len(normSort) == 0 || len(urlSort) == 0 {
return ""
}
if len(urlSort) == 0 && isDefault {
// if sort is sorted as default add arrow tho this table header
if isDefault {
return svg.RenderHTML("octicon-triangle-down", 16)
}
} else {
// if sort arg is in url test if it correlates with column header sort arguments
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
switch urlSort {
case normSort:
// the table is sorted with this header normal
return svg.RenderHTML("octicon-triangle-up", 16)
case revSort:
// the table is sorted with this header reverse
return svg.RenderHTML("octicon-triangle-down", 16)
}
}
// the table is NOT sorted with this header
return ""
}
// Actioner describes an action
type Actioner interface {
GetOpType() activities_model.ActionType
GetActUserName(ctx context.Context) string
GetRepoUserName(ctx context.Context) string
GetRepoName(ctx context.Context) string
GetRepoPath(ctx context.Context) string
GetRepoLink(ctx context.Context) string
GetBranch() string
GetContent() string
GetCreate() time.Time
GetIssueInfos() []string
}
// actionIcon accepts an action operation type and returns an icon class name.
func actionIcon(opType activities_model.ActionType) string {
switch opType {
case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo:
return "repo"
case activities_model.ActionCommitRepo:
return "git-commit"
case activities_model.ActionDeleteBranch:
return "git-branch"
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
return "git-merge"
case activities_model.ActionCreatePullRequest:
return "git-pull-request"
case activities_model.ActionClosePullRequest:
return "git-pull-request-closed"
case activities_model.ActionCreateIssue:
return "issue-opened"
case activities_model.ActionCloseIssue:
return "issue-closed"
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
return "issue-reopened"
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
return "comment-discussion"
case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete:
return "mirror"
case activities_model.ActionApprovePullRequest:
return "check"
case activities_model.ActionRejectPullRequest:
return "file-diff"
case activities_model.ActionPublishRelease, activities_model.ActionPushTag, activities_model.ActionDeleteTag:
return "tag"
case activities_model.ActionPullReviewDismissed:
return "x"
default:
return "question"
}
}
// ActionContent2Commits converts action content to push commits
func ActionContent2Commits(act Actioner) *repository.PushCommits {
push := repository.NewPushCommits()
if act == nil || act.GetContent() == "" {
return push
}
if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil {
log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err)
}
if push.Len == 0 {
push.Len = len(push.Commits)
}
return push
}
// migrationIcon returns a SVG name matching the service an issue/comment was migrated from
func migrationIcon(hostname string) string {
switch hostname {
case "github.com":
return "octicon-mark-github"
default:
return "gitea-git"
}
}
type remoteAddress struct {
Address string
Username string
Password string
}
func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string) remoteAddress {
ret := remoteAddress{}
u, err := gitrepo.GitRemoteGetURL(ctx, m, remoteName)
if err != nil {
log.Error("GetRemoteURL %v", err)
return ret
}
if u.Scheme != "ssh" && u.Scheme != "file" {
if u.User != nil {
ret.Username = u.User.Username()
ret.Password, _ = u.User.Password()
}
}
// The URL stored in the git repo could contain authentication,
// erase it, or it will be shown in the UI.
u.User = nil
ret.Address = u.String()
// Why not use m.OriginalURL to set ret.Address?
// It should be OK to use it, since m.OriginalURL should be the same as the authentication-erased URL from the Git repository.
// However, the old code has already stored authentication in m.OriginalURL when updating mirror settings.
// That means we need to use "giturl.Parse" for m.OriginalURL again to ensure authentication is erased.
// Instead of doing this, why not directly use the authentication-erased URL from the Git repository?
// It should be the same as long as there are no bugs.
return ret
}
func filenameIsImage(filename string) bool {
mimeType := mime.TypeByExtension(filepath.Ext(filename))
return strings.HasPrefix(mimeType, "image/")
}
func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
if ec != nil {
def, err := ec.GetDefinitionForFilename(filename)
if err == nil && def.TabWidth >= 1 && def.TabWidth <= 16 {
return "tab-size-" + strconv.Itoa(def.TabWidth)
}
}
return "tab-size-4"
}
type MiscUtils struct {
ctx context.Context
}
func NewMiscUtils(ctx context.Context) *MiscUtils {
return &MiscUtils{ctx: ctx}
}
type MarkdownEditorContext struct {
PreviewMode string // "comment", "wiki", or empty for general
PreviewContext string // the path for resolving the links in the preview (repo preview already has default correct value)
PreviewLink string
MentionsLink string
}
func (m *MiscUtils) MarkdownEditorComment(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "comment",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}
func (m *MiscUtils) MarkdownEditorWiki(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "wiki",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}
func (m *MiscUtils) MarkdownEditorGeneral(owner *user_model.User) *MarkdownEditorContext {
ret := &MarkdownEditorContext{PreviewLink: setting.AppSubURL + "/-/markup"}
if owner != nil {
ret.PreviewContext = owner.HomeLink()
ret.MentionsLink = owner.HomeLink() + "/-/mentions-in-owner"
}
return ret
}
+327
View File
@@ -0,0 +1,327 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"encoding/hex"
"fmt"
"html/template"
"math"
"net/url"
"regexp"
"strings"
"unicode"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper"
"gitea.dev/models/repo"
"gitea.dev/modules/charset"
"gitea.dev/modules/emoji"
"gitea.dev/modules/git"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"gitea.dev/services/webtheme"
)
type RenderUtils struct {
ctx reqctx.RequestContext
}
func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
return &RenderUtils{ctx: ctx}
}
// RenderCommitMessage renders commit message with XSS-safe and special links.
func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML {
cleanMsg := template.HTML(template.HTMLEscapeString(msg))
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
// "repo" can be nil when rendering commit messages for deleted repositories in a user's dashboard feed.
fullMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), cleanMsg)
if err != nil {
log.Error("PostProcessCommitMessage: %v", err)
return ""
}
msgLines := strings.Split(strings.TrimSpace(string(fullMessage)), "\n")
if len(msgLines) == 0 {
return ""
}
return renderCodeBlock(template.HTML(msgLines[0]))
}
// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
// the provided default url, handling for special links without email to links.
func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML {
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
lineEnd := strings.IndexByte(msgLine, '\n')
if lineEnd > 0 {
msgLine = msgLine[:lineEnd]
}
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
if len(msgLine) == 0 {
return ""
}
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
renderedMessage, err := markup.PostProcessCommitMessageSubject(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), urlDefault, template.HTMLEscapeString(msgLine))
if err != nil {
log.Error("PostProcessCommitMessageSubject: %v", err)
return ""
}
return renderCodeBlock(template.HTML(renderedMessage))
}
// RenderCommitBody extracts the body of a commit message without its title.
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
body = strings.TrimFunc(body, unicode.IsSpace)
if body == "" {
return ""
}
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
htmlContent := template.HTML(template.HTMLEscapeString(body))
renderedMessage, err := markup.PostProcessCommitMessage(rctx, htmlContent)
if err != nil {
log.Error("PostProcessCommitMessage: %v", err)
return ""
}
return renderedMessage
}
// Match text that is between back ticks.
var codeMatcher = regexp.MustCompile("`([^`]+)`")
// renderCodeBlock renders "`…`" as highlighted "<code>" block, intended for issue and PR titles
func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags
return template.HTML(htmlWithCodeTags)
}
// RenderIssueTitle renders issue/pull title with defined post processors
func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML {
renderedText, err := markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), template.HTMLEscapeString(text))
if err != nil {
log.Error("PostProcessIssueTitle: %v", err)
return ""
}
return renderCodeBlock(template.HTML(renderedText))
}
// RenderIssueSimpleTitle only renders with emoji and inline code block
func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
ret := ut.RenderEmoji(text)
ret = renderCodeBlock(ret)
return ret
}
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
var extraCSSClasses string
textColor := util.ContrastColor(label.Color)
labelScope := label.ExclusiveScope()
descriptionText := emoji.ReplaceAliases(label.Description)
if label.IsArchived() {
extraCSSClasses = "archived-label"
descriptionText = fmt.Sprintf("(%s) %s", locale.TrString("archived"), descriptionText)
}
if labelScope == "" {
// Regular label
return htmlutil.HTMLFormat(`<span class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></span>`,
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
}
// Scoped label
scopeHTML := ut.RenderEmoji(labelScope)
itemHTML := ut.RenderEmoji(label.Name[len(labelScope)+1:])
// Make scope and item background colors slightly darker and lighter respectively.
// More contrast needed with higher luminance, empirically tweaked.
luminance := util.GetRelativeLuminance(label.Color)
contrast := 0.01 + luminance*0.03
// Ensure we add the same amount of contrast also near 0 and 1.
darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
lighten := contrast + math.Max(contrast-luminance, 0.0)
// Compute the factor to keep RGB values proportional.
darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
r, g, b := util.HexToRBGColor(label.Color)
scopeBytes := []byte{
uint8(math.Min(math.Round(r*darkenFactor), 255)),
uint8(math.Min(math.Round(g*darkenFactor), 255)),
uint8(math.Min(math.Round(b*darkenFactor), 255)),
}
itemBytes := []byte{
uint8(math.Min(math.Round(r*lightenFactor), 255)),
uint8(math.Min(math.Round(g*lightenFactor), 255)),
uint8(math.Min(math.Round(b*lightenFactor), 255)),
}
itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes)
if label.ExclusiveOrder > 0 {
// <scope> | <label> | <order>
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right">%d</div>`+
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML,
label.ExclusiveOrder)
}
// <scope> | <label>
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML)
}
// RenderEmoji renders html text with emoji post processors
func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))
if err != nil {
log.Error("RenderEmoji: %v", err)
return ""
}
return template.HTML(renderedText)
}
// reactionToEmoji renders emoji for use in reactions
func reactionToEmoji(reaction string) template.HTML {
val := emoji.FromCode(reaction)
if val != nil {
return template.HTML(val.Emoji)
}
val = emoji.FromAlias(reaction)
if val != nil {
return template.HTML(val.Emoji)
}
return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
}
func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:revive // variable naming triggers on Html, wants HTML
output, err := markdown.RenderString(markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()), input)
if err != nil {
log.Error("RenderString: %v", err)
}
return output
}
// RenderPackageMarkdown renders package page Markdown so relative links resolve against the
// linked repository's default branch instead of the site root, falling back to plain rendering
// when there is no linked repository. pkgTreePath optionally roots links in a subdirectory
// (e.g. npm's repository.directory for monorepo packages).
func (ut *RenderUtils) RenderPackageMarkdown(input string, linkedRepo *repo.Repository, pkgTreePath ...string) template.HTML {
if linkedRepo == nil {
return `<div class="markup markdown">` + ut.MarkdownToHtml(input) + `</div>`
}
rctx := renderhelper.NewRenderContextRepoFile(ut.ctx, linkedRepo, renderhelper.RepoFileOptions{
CurrentRefSubURL: git.RefNameFromBranch(linkedRepo.DefaultBranch).RefWebLinkPath(),
CurrentTreePath: util.OptionalArg(pkgTreePath),
})
output, err := markdown.RenderString(rctx, input)
if err != nil {
log.Error("RenderString: %v", err)
}
return `<div class="markup markdown">` + output + `</div>`
}
func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
isPullRequest := issue != nil && issue.IsPull
baseLink := fmt.Sprintf("%s/%s", repoLink, util.Iif(isPullRequest, "pulls", "issues"))
var htmlCode htmlutil.HTMLBuilder
htmlCode.WriteHTML(`<span class="labels-list">`)
for _, label := range labels {
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
if label == nil {
continue
}
htmlCode.WriteFormat(`<a class="item" href="%s?labels=%d">`, baseLink, label.ID)
htmlCode.WriteHTML(ut.RenderLabel(label))
htmlCode.WriteHTML("</a>")
}
htmlCode.WriteHTML("</span>")
return htmlCode.HTMLString()
}
func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML {
svgName := "octicon-paintbrush"
switch info.ColorScheme {
case "dark":
svgName = "octicon-moon"
case "light":
svgName = "octicon-sun"
case "auto":
svgName = "gitea-eclipse"
}
icon := svg.RenderHTML(svgName, iconSize)
extraIcon := svg.RenderHTML(info.GetExtraIconName(), iconSize)
return htmlutil.HTMLFormat(`<div class="theme-menu-item" data-tooltip-content="%s">%s %s %s</div>`, info.GetDescription(), icon, info.DisplayName, extraIcon)
}
func (ut *RenderUtils) RenderFlashMessage(typ, msg string) template.HTML {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
cls := typ
// legacy logic: "negative" for error, "positive" for success
switch cls {
case "error":
cls = "negative"
case "success":
cls = "positive"
}
var msgContent template.HTML
if strings.Contains(msg, "</pre>") || strings.Contains(msg, "</details>") || strings.Contains(msg, "</ul>") || strings.Contains(msg, "</div>") {
// If the message contains some known "block" elements, no need to do more alignment or line-break processing, just sanitize it directly.
msgContent = sanitizeHTML(msg)
} else if !strings.Contains(msg, "\n") {
// If the message is a single line, center-align it by wrapping it
msgContent = htmlutil.HTMLFormat(`<div class="tw-text-center">%s</div>`, sanitizeHTML(msg))
} else {
// For a multi-line message, preserve line breaks, and left-align it.
msgContent = htmlutil.HTMLFormat(`%s`, sanitizeHTML(strings.ReplaceAll(msg, "\n", "<br>")))
}
return htmlutil.HTMLFormat(`<div class="ui %s message flash-message flash-%s">%s</div>`, cls, typ, msgContent)
}
func (ut *RenderUtils) RenderUnicodeEscapeToggleButton(escapeStatus *charset.EscapeStatus) template.HTML {
if escapeStatus == nil || !escapeStatus.Escaped {
return ""
}
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
var title template.HTML
if escapeStatus.HasAmbiguous {
title += locale.Tr("repo.ambiguous_runes_line")
} else if escapeStatus.HasInvisible {
title += locale.Tr("repo.invisible_runes_line")
}
return htmlutil.HTMLFormat(`<button type="button" class="toggle-escape-button btn interact-bg" title="%s"></button>`, title)
}
func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *charset.EscapeStatus) template.HTML {
if combined == nil || !combined.Escaped {
return ""
}
return `<td class="lines-escape">` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `</td>`
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"strings"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
)
func commentTimelineEventIsWipToggle(c *issues_model.Comment) (isToggle, isWip bool) {
title1, ok1 := issues_model.CutWorkInProgressPrefix(c.OldTitle)
title2, ok2 := issues_model.CutWorkInProgressPrefix(c.NewTitle)
return ok1 != ok2 && strings.TrimSpace(title1) == strings.TrimSpace(title2), ok2
}
func (ut *RenderUtils) RenderTimelineEventBadge(c *issues_model.Comment) template.HTML {
if c.Type == issues_model.CommentTypeChangeTitle {
isToggle, isWip := commentTimelineEventIsWipToggle(c)
if !isToggle {
return svg.RenderHTML("octicon-pencil")
}
return util.Iif(isWip, svg.RenderHTML("octicon-git-pull-request-draft"), svg.RenderHTML("octicon-eye"))
}
setting.PanicInDevOrTesting("unimplemented comment type %v: %v", c.Type, c)
return htmlutil.HTMLFormat("(CommentType:%v)", c.Type)
}
func (ut *RenderUtils) RenderTimelineEventComment(c *issues_model.Comment, createdStr template.HTML) template.HTML {
if c.Type == issues_model.CommentTypeChangeTitle {
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
isToggle, isWip := commentTimelineEventIsWipToggle(c)
if !isToggle {
return locale.Tr("repo.issues.change_title_at", ut.RenderEmoji(c.OldTitle), ut.RenderEmoji(c.NewTitle), createdStr)
}
trKey := util.Iif(isWip, "repo.pulls.marked_as_work_in_progress_at", "repo.pulls.marked_as_ready_for_review_at")
return locale.Tr(trKey, createdStr)
}
setting.PanicInDevOrTesting("unimplemented comment type %v: %v", c.Type, c)
return htmlutil.HTMLFormat("(Comment:%v,%v)", c.Type, c.Content)
}
@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
)
func TestRenderTimelineEventComment(t *testing.T) {
ctx := reqctx.NewRequestContextForTest(t.Context())
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
ut := &RenderUtils{ctx: ctx}
var createdStr template.HTML = "(created-at)"
c := &issues_model.Comment{Type: issues_model.CommentTypeChangeTitle, OldTitle: "WIP: title", NewTitle: "title"}
assert.Equal(t, "repo.pulls.marked_as_ready_for_review_at:(created-at)", string(ut.RenderTimelineEventComment(c, createdStr)))
c = &issues_model.Comment{Type: issues_model.CommentTypeChangeTitle, OldTitle: "title", NewTitle: "WIP: title"}
assert.Equal(t, "repo.pulls.marked_as_work_in_progress_at:(created-at)", string(ut.RenderTimelineEventComment(c, createdStr)))
c = &issues_model.Comment{Type: issues_model.CommentTypeChangeTitle, OldTitle: "title", NewTitle: "WIP: new title"}
assert.Equal(t, "repo.issues.change_title_at:title,WIP: new title,(created-at)", string(ut.RenderTimelineEventComment(c, createdStr)))
}
+268
View File
@@ -0,0 +1,268 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"context"
"html/template"
"os"
"strings"
"testing"
"gitea.dev/models/issues"
"gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
)
func testInput() string {
s := ` space @mention-user<SPACE><SPACE>
/just/a/path.bin
https://example.com/file.bin
[local link](file.bin)
[remote link](https://example.com)
[[local link|file.bin]]
[[remote link|https://example.com]]
![local image](image.jpg)
![remote image](https://example.com/image.jpg)
[[local image|image.jpg]]
[[remote link|https://example.com/image.jpg]]
https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
:+1:
mail@domain.com
@mention-user test
#123
space<SPACE><SPACE>
`
return strings.ReplaceAll(s, "<SPACE>", " ")
}
func TestMain(m *testing.M) {
setting.Markdown.RenderOptionsComment.ShortIssuePattern = true
markup.Init(&markup.RenderHelperFuncs{
IsUsernameMentionable: func(ctx context.Context, username string) bool {
return username == "mention-user"
},
})
os.Exit(m.Run())
}
func newTestRenderUtils(t *testing.T) *RenderUtils {
ctx := reqctx.NewRequestContextForTest(t.Context())
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
return NewRenderUtils(ctx)
}
func TestRenderRepoComment(t *testing.T) {
mockRepo := &repo.Repository{
ID: 1, OwnerName: "user13", Name: "repo11",
Owner: &user_model.User{ID: 13, Name: "user13"},
Units: []*repo.RepoUnit{},
}
t.Run("RenderCommitBody", func(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
type args struct {
msg string
}
tests := []struct {
name string
args args
want template.HTML
}{
{
name: "multiple lines",
args: args{
msg: "first line\nsecond line",
},
want: "second line",
},
{
name: "multiple lines with leading newlines",
args: args{
msg: "\n\n\n\nfirst line\nsecond line",
},
want: "second line",
},
{
name: "multiple lines with trailing newlines",
args: args{
msg: "first line\nsecond line\n\n\n",
},
want: "second line",
},
}
ut := newTestRenderUtils(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, mockRepo), "RenderCommitBody(%v, %v)", tt.args.msg, nil)
})
}
expected := `/just/a/path.bin
<a href="https://example.com/file.bin">https://example.com/file.bin</a>
[local link](file.bin)
[remote link](<a href="https://example.com">https://example.com</a>)
[[local link|file.bin]]
[[remote link|<a href="https://example.com">https://example.com</a>]]
![local image](image.jpg)
![remote image](<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>)
[[local image|image.jpg]]
[[remote link|<a href="https://example.com/image.jpg">https://example.com/image.jpg</a>]]
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code>88fc37a3c0</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span>
<a href="mailto:mail@domain.com">mail@domain.com</a>
<a href="/mention-user">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space`
assert.Equal(t, expected, string(newTestRenderUtils(t).RenderCommitBody(testInput(), mockRepo)))
})
t.Run("RenderCommitMessage", func(t *testing.T) {
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> `
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo))
})
t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) {
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
})
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
// a bare URL in the subject must not hijack the default link
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
})
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
// a URL embedded in larger subject text still becomes its own link
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
})
t.Run("RenderIssueTitle", func(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
expected := ` space @mention-user<SPACE><SPACE>
/just/a/path.bin
https://example.com/file.bin
[local link](file.bin)
[remote link](https://example.com)
[[local link|file.bin]]
[[remote link|https://example.com]]
![local image](image.jpg)
![remote image](https://example.com/image.jpg)
[[local image|image.jpg]]
[[remote link|https://example.com/image.jpg]]
https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span>
mail@domain.com
@mention-user test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space<SPACE><SPACE>
`
expected = strings.ReplaceAll(expected, "<SPACE>", " ")
assert.Equal(t, expected, string(newTestRenderUtils(t).RenderIssueTitle(testInput(), mockRepo)))
})
}
func TestRenderMarkdownToHtml(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
/just/a/path.bin
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
<a href="/file.bin" rel="nofollow">local link</a>
<a href="https://example.com" rel="nofollow">remote link</a>
<a href="/file.bin" rel="nofollow">local link</a>
<a href="https://example.com" rel="nofollow">remote link</a>
<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<span class="emoji" aria-label="thumbs up">👍</span>
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
<a href="/mention-user" rel="nofollow">@mention-user</a> test
#123
space</p>
`
assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput())))
}
func TestRenderPackageMarkdown(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
mockRepo := &repo.Repository{
ID: 1, OwnerName: "user13", Name: "repo11", DefaultBranch: "main",
Owner: &user_model.User{ID: 13, Name: "user13"},
Units: []*repo.RepoUnit{},
}
ut := newTestRenderUtils(t)
t.Run("LinkedRepoWithDirectory", func(t *testing.T) {
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)\n![logo](logo.png)", mockRepo, "pkg-subdir")
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/pkg-subdir/docs/getting-started.md" rel="nofollow">docs</a>
<a href="/user13/repo11/src/branch/main/pkg-subdir/logo.png" target="_blank" rel="nofollow noopener"><img src="/user13/repo11/media/branch/main/pkg-subdir/logo.png" alt="logo"/></a></p>
</div>`
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
})
t.Run("LinkedRepoWithEmptyDirectory", func(t *testing.T) {
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", mockRepo, "")
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/docs/getting-started.md" rel="nofollow">docs</a></p>
</div>`
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
})
t.Run("UnlinkedRepo", func(t *testing.T) {
rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", nil, "pkg-subdir")
expected := `<div class="markup markdown"><p><a href="/docs/getting-started.md" rel="nofollow">docs</a></p>
</div>`
assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
})
}
func TestRenderLabels(t *testing.T) {
ut := newTestRenderUtils(t)
label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
issue := &issues.Issue{}
expected := `/owner/repo/issues?labels=123`
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
label = &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
issue = &issues.Issue{IsPull: true}
expected = `/owner/repo/pulls?labels=123`
assert.Contains(t, ut.RenderLabels([]*issues.Label{label}, "/owner/repo", issue), expected)
expectedLabel := `<span class="ui label " style="color: #fff !important; background-color: label-color !important;" data-tooltip-content title=""><span class="gt-ellipsis">label-name</span></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true}
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">&gt;</div></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true, ExclusiveOrder: 1}
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important">&lt;</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">&gt;</div><div class="ui label scope-right">1</div></span>`
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
}
func TestUserMention(t *testing.T) {
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
assert.Equal(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"gitea.dev/modules/util"
)
type SliceUtils struct{}
func NewSliceUtils() *SliceUtils {
return &SliceUtils{}
}
func (su *SliceUtils) Contains(s, v any) bool {
if s == nil {
return false
}
sv := reflect.ValueOf(s)
if sv.Kind() != reflect.Slice && sv.Kind() != reflect.Array {
panic(fmt.Sprintf("invalid type, expected slice or array, but got: %T", s))
}
for i := 0; i < sv.Len(); i++ {
it := sv.Index(i)
if !it.CanInterface() {
continue
}
if it.Interface() == v {
return true
}
}
return false
}
// JoinInt64 joins a slice of int64 values into a comma-separated string.
func (su *SliceUtils) JoinInt64(values []int64) string {
if len(values) == 0 {
return ""
}
strs := make([]string, len(values))
for i, v := range values {
strs[i] = strconv.FormatInt(v, 10)
}
return strings.Join(strs, ",")
}
func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct {
IsIncluded bool
ToggledIDs string
},
) {
ret.IsIncluded = slices.Contains(values, target)
if ret.IsIncluded {
ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target))
} else {
ret.ToggledIDs = su.JoinInt64(append(values, target))
}
return ret
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"fmt"
"html/template"
"strings"
"gitea.dev/modules/util"
)
type StringUtils struct{}
var stringUtils = StringUtils{}
func NewStringUtils() *StringUtils {
return &stringUtils
}
func (su *StringUtils) ToString(v any) string {
switch v := v.(type) {
case string:
return v
case template.HTML:
return string(v)
case fmt.Stringer:
return v.String()
default:
return fmt.Sprint(v)
}
}
func (su *StringUtils) HasPrefix(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
}
func (su *StringUtils) Contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func (su *StringUtils) Split(s, sep string) []string {
return strings.Split(s, sep)
}
func (su *StringUtils) Join(a []string, sep string) string {
return strings.Join(a, sep)
}
func (su *StringUtils) Cut(s, sep string) []any {
before, after, found := strings.Cut(s, sep)
return []any{before, after, found}
}
func (su *StringUtils) EllipsisString(s string, maxLength int) string {
return util.EllipsisDisplayString(s, maxLength)
}
func (su *StringUtils) ToUpper(s string) string {
return strings.ToUpper(s)
}
func (su *StringUtils) TrimPrefix(s, prefix string) string {
return strings.TrimPrefix(s, prefix)
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package templates
import (
"html/template"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDict(t *testing.T) {
type M map[string]any
cases := []struct {
args []any
want map[string]any
}{
{[]any{"a", 1, "b", 2}, M{"a": 1, "b": 2}},
{[]any{".", M{"base": 1}, "b", 2}, M{"base": 1, "b": 2}},
{[]any{"a", 1, ".", M{"extra": 2}}, M{"a": 1, "extra": 2}},
{[]any{"a", 1, ".", map[string]int{"int": 2}}, M{"a": 1, "int": 2}},
{[]any{".", nil, "b", 2}, M{"b": 2}},
}
for _, c := range cases {
got, err := dict(c.args...)
if assert.NoError(t, err) {
assert.Equal(t, c.want, got)
}
}
bads := []struct {
args []any
}{
{[]any{"a", 1, "b"}},
{[]any{1}},
{[]any{struct{}{}}},
}
for _, c := range bads {
_, err := dict(c.args...)
assert.Error(t, err)
}
}
func TestUtils(t *testing.T) {
execTmpl := func(code string, data any) string {
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
template.Must(tmpl.Parse(code))
w := &strings.Builder{}
assert.NoError(t, tmpl.Execute(w, data))
return w.String()
}
actual := execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "a"})
assert.Equal(t, "true", actual)
actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "x"})
assert.Equal(t, "false", actual)
actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []int64{1, 2}, "Value": int64(2)})
assert.Equal(t, "true", actual)
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "b"})
assert.Equal(t, "true", actual)
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
assert.Equal(t, "false", actual)
// Test JoinInt64
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}})
assert.Equal(t, "1,2,3", actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}})
assert.Empty(t, actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}})
assert.Equal(t, "42", actual)
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
// error is like this: `template: test:1:12: executing "test" at <SliceUtils.Contains>: error calling Contains: ...`
err := tmpl.Execute(io.Discard, map[string]any{"Slice": struct{}{}})
assert.ErrorContains(t, err, "invalid type, expected slice or array")
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package vars
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors
// if error occurs, the error part doesn't change and is returned as it is.
func Expand(template string, vars map[string]string) (string, error) {
// in the future, if necessary, we can introduce some escape-char,
// for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'.
var buf strings.Builder
var err error
posBegin := 0
strLen := len(template)
for posBegin < strLen {
// find the next `{`
pos := strings.IndexByte(template[posBegin:], '{')
if pos == -1 {
buf.WriteString(template[posBegin:])
break
}
// copy texts between vars
buf.WriteString(template[posBegin : posBegin+pos])
// find the var between `{` and `}`/end
posBegin += pos
posEnd := posBegin + 1
for posEnd < strLen {
if template[posEnd] == '}' {
posEnd++
break
} // in the future, if we need to support escape chars, we can do: if (isEscapeChar) { posEnd+=2 }
posEnd++
}
// the var part, it can be "{", "{}", "{..." or or "{...}"
part := template[posBegin:posEnd]
posBegin = posEnd
if part == "{}" || part[len(part)-1] != '}' {
// treat "{}" or "{..." as error
err = fmt.Errorf("wrong syntax found in %s", template)
buf.WriteString(part)
} else {
// now we get a valid key "{...}"
key := part[1 : len(part)-1]
keyFirst, _ := utf8.DecodeRuneInString(key)
if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) {
// if the key doesn't start with a letter, then we do not treat it as a var now
buf.WriteString(part)
} else {
// look up in the map
if val, ok := vars[key]; ok {
buf.WriteString(val)
} else {
// write the non-existing var as it is
buf.WriteString(part)
err = fmt.Errorf("the variable %s is missing for %s", key, template)
}
}
}
}
return buf.String(), err
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package vars
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandVars(t *testing.T) {
kases := []struct {
tmpl string
data map[string]string
out string
error bool
}{
{
tmpl: "{a}",
data: map[string]string{
"a": "1",
},
out: "1",
},
{
tmpl: "expand {a}, {b} and {c}, with non-var { } {#}",
data: map[string]string{
"a": "1",
"b": "2",
"c": "3",
},
out: "expand 1, 2 and 3, with non-var { } {#}",
},
{
tmpl: "中文内容 {一}, {二} 和 {三} 中文结尾",
data: map[string]string{
"一": "11",
"二": "22",
"三": "33",
},
out: "中文内容 11, 22 和 33 中文结尾",
},
{
tmpl: "expand {{a}, {b} and {c}",
data: map[string]string{
"a": "foo",
"b": "bar",
},
out: "expand {{a}, bar and {c}",
error: true,
},
{
tmpl: "expand } {} and {",
out: "expand } {} and {",
error: true,
},
}
for _, kase := range kases {
t.Run(kase.tmpl, func(t *testing.T) {
res, err := Expand(kase.tmpl, kase.data)
assert.Equal(t, kase.out, res)
if kase.error {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}