初始提交: Gitea 项目代码
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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¶ms", new-param-pairs...} => "old¶ms&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)
|
||||
}
|
||||
@@ -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="""><></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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"><</a><form action="?q=%2f"><</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")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 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 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 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", 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"><</div><div class="ui label scope-right" style="color: #fff !important; background-color: #000000 !important">></div></span>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
||||
label = &issues.Label{ID: 123, Name: "</>", Exclusive: true, ExclusiveOrder: 1}
|
||||
expectedLabel = `<span class="ui label scope-parent" data-tooltip-content title=""><div class="ui label scope-left" style="color: #fff !important; background-color: #000000 !important"><</div><div class="ui label scope-middle" style="color: #fff !important; background-color: #000000 !important">></div><div class="ui label scope-right">1</div></span>`
|
||||
assert.Equal(t, expectedLabel, string(ut.RenderLabel(label)))
|
||||
}
|
||||
|
||||
func TestUserMention(t *testing.T) {
|
||||
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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user