forked from hinterland/hearth
feat: vendor gitea 1.16.2
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
@@ -18,23 +15,3 @@ func AssetFS() *assetfs.LayeredFS {
|
||||
func CustomAssets() *assetfs.Layer {
|
||||
return assetfs.Local("custom", setting.CustomPath, "templates")
|
||||
}
|
||||
|
||||
func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
|
||||
files, err := assets.ListAllFiles(".", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.DeleteFunc(files, func(file string) bool {
|
||||
return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
|
||||
files, err := assets.ListAllFiles(".", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.DeleteFunc(files, func(file string) bool {
|
||||
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -6,29 +6,27 @@ package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/public"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/templates/eval"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/gitdiff"
|
||||
"code.gitea.io/gitea/services/webtheme"
|
||||
)
|
||||
|
||||
// NewFuncMap returns functions for injecting to templates
|
||||
func NewFuncMap() template.FuncMap {
|
||||
func newFuncMapWebPage() template.FuncMap {
|
||||
return map[string]any{
|
||||
"ctx": func() any { return nil }, // template context function
|
||||
|
||||
"DumpVar": dumpVar,
|
||||
"NIL": func() any { return nil },
|
||||
|
||||
@@ -41,8 +39,6 @@ func NewFuncMap() template.FuncMap {
|
||||
"QueryEscape": queryEscape,
|
||||
"QueryBuild": QueryBuild,
|
||||
"SanitizeHTML": SanitizeHTML,
|
||||
"URLJoin": util.URLJoin,
|
||||
"DotEscape": dotEscape,
|
||||
|
||||
"PathEscape": url.PathEscape,
|
||||
"PathEscapeSegments": util.PathEscapeSegments,
|
||||
@@ -63,6 +59,7 @@ func NewFuncMap() template.FuncMap {
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// time / number / format
|
||||
"ShortSha": base.ShortSha,
|
||||
"FileSize": base.FileSize,
|
||||
"CountFmt": countFmt,
|
||||
"Sec2Hour": util.SecToHours,
|
||||
@@ -73,6 +70,9 @@ func NewFuncMap() template.FuncMap {
|
||||
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
|
||||
},
|
||||
|
||||
"AssetURI": public.AssetURI,
|
||||
"ScriptImport": scriptImport,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// setting
|
||||
"AppName": func() string {
|
||||
@@ -84,25 +84,12 @@ func NewFuncMap() template.FuncMap {
|
||||
"AssetUrlPrefix": func() string {
|
||||
return setting.StaticURLPrefix + "/assets"
|
||||
},
|
||||
"AppUrl": func() string {
|
||||
// The usage of AppUrl should be avoided as much as possible,
|
||||
// because the AppURL(ROOT_URL) may not match user's visiting site and the ROOT_URL in app.ini may be incorrect.
|
||||
// And it's difficult for Gitea to guess absolute URL correctly with zero configuration,
|
||||
// because Gitea doesn't know whether the scheme is HTTP or HTTPS unless the reverse proxy could tell Gitea.
|
||||
return setting.AppURL
|
||||
},
|
||||
"AppVer": func() string {
|
||||
return setting.AppVer
|
||||
},
|
||||
"AppDomain": func() string { // documented in mail-templates.md
|
||||
"AppDomain": func() string { // TODO: helm registry still uses it, need to use current request host in the future
|
||||
return setting.Domain
|
||||
},
|
||||
"AssetVersion": func() string {
|
||||
return setting.AssetVersion
|
||||
},
|
||||
"DefaultShowFullName": func() bool {
|
||||
return setting.UI.DefaultShowFullName
|
||||
},
|
||||
"ShowFooterTemplateLoadTime": func() bool {
|
||||
return setting.Other.ShowFooterTemplateLoadTime
|
||||
},
|
||||
@@ -130,7 +117,6 @@ func NewFuncMap() template.FuncMap {
|
||||
"DisableWebhooks": func() bool {
|
||||
return setting.DisableWebhooks
|
||||
},
|
||||
"UserThemeName": userThemeName,
|
||||
"NotificationSettings": func() map[string]any {
|
||||
return map[string]any{
|
||||
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
|
||||
@@ -149,8 +135,7 @@ func NewFuncMap() template.FuncMap {
|
||||
"ReactionToEmoji": reactionToEmoji,
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// misc
|
||||
"ShortSha": base.ShortSha,
|
||||
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
|
||||
"ActionContent2Commits": ActionContent2Commits,
|
||||
"IsMultilineCommitMessage": isMultilineCommitMessage,
|
||||
"CommentMustAsDiff": gitdiff.CommentMustAsDiff,
|
||||
@@ -184,11 +169,6 @@ func queryEscape(s string) template.URL {
|
||||
return template.URL(url.QueryEscape(s))
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -217,16 +197,6 @@ func evalTokens(tokens ...any) (any, error) {
|
||||
return n.Value, err
|
||||
}
|
||||
|
||||
func userThemeName(user *user_model.User) string {
|
||||
if user == nil || user.Theme == "" {
|
||||
return setting.UI.DefaultTheme
|
||||
}
|
||||
if webtheme.IsThemeAvailable(user.Theme) {
|
||||
return user.Theme
|
||||
}
|
||||
return setting.UI.DefaultTheme
|
||||
}
|
||||
|
||||
func isQueryParamEmpty(v any) bool {
|
||||
return v == nil || v == false || v == 0 || v == int64(0) || v == ""
|
||||
}
|
||||
@@ -322,3 +292,30 @@ func QueryBuild(a ...any) template.URL {
|
||||
}
|
||||
return template.URL(s)
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
scriptImportRemainingPart string
|
||||
},
|
||||
) {
|
||||
// add onerror handler to alert users when the script fails to load:
|
||||
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
|
||||
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
|
||||
// the message will be directly put in the onerror JS code's string
|
||||
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
|
||||
if !setting.IsProd {
|
||||
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
|
||||
}
|
||||
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
|
||||
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
|
||||
return ret
|
||||
})
|
||||
|
||||
func scriptImport(path string, typ ...string) template.HTML {
|
||||
if len(typ) > 0 {
|
||||
if typ[0] == "module" {
|
||||
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||
}
|
||||
panic("unsupported script type: " + typ[0])
|
||||
}
|
||||
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -168,3 +169,22 @@ func TestQueryBuild(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -6,21 +6,18 @@ package templates
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
texttemplate "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates/scopedtmpl"
|
||||
@@ -31,58 +28,27 @@ type TemplateExecutor scopedtmpl.TemplateExecutor
|
||||
|
||||
type TplName string
|
||||
|
||||
type HTMLRender struct {
|
||||
type tmplRender struct {
|
||||
templates atomic.Pointer[scopedtmpl.ScopedTemplate]
|
||||
|
||||
collectTemplateNames func() ([]string, error)
|
||||
readTemplateContent func(name string) ([]byte, error)
|
||||
}
|
||||
|
||||
var (
|
||||
htmlRender *HTMLRender
|
||||
htmlRenderOnce sync.Once
|
||||
)
|
||||
|
||||
var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
|
||||
|
||||
func (h *HTMLRender) HTML(w io.Writer, status int, tplName TplName, data any, ctx 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 := h.TemplateLookup(name, ctx)
|
||||
if err != nil {
|
||||
return texttemplate.ExecError{Name: name, Err: err}
|
||||
}
|
||||
return t.Execute(w, data)
|
||||
func (h *tmplRender) Templates() *scopedtmpl.ScopedTemplate {
|
||||
return h.templates.Load()
|
||||
}
|
||||
|
||||
func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
|
||||
tmpls := h.templates.Load()
|
||||
if tmpls == nil {
|
||||
return nil, ErrTemplateNotInitialized
|
||||
}
|
||||
m := NewFuncMap()
|
||||
m["ctx"] = func() any { return ctx }
|
||||
return tmpls.Executor(name, m)
|
||||
}
|
||||
|
||||
func (h *HTMLRender) CompileTemplates() error {
|
||||
assets := AssetFS()
|
||||
extSuffix := ".tmpl"
|
||||
func (h *tmplRender) recompileTemplates(dummyFuncMap template.FuncMap) error {
|
||||
tmpls := scopedtmpl.NewScopedTemplate()
|
||||
tmpls.Funcs(NewFuncMap())
|
||||
files, err := ListWebTemplateAssetNames(assets)
|
||||
tmpls.Funcs(dummyFuncMap)
|
||||
names, err := h.collectTemplateNames()
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
for _, file := range files {
|
||||
if !strings.HasSuffix(file, extSuffix) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(file, extSuffix)
|
||||
for _, name := range names {
|
||||
tmpl := tmpls.New(filepath.ToSlash(name))
|
||||
buf, err := assets.ReadFile(file)
|
||||
buf, err := h.readTemplateContent(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,55 +61,20 @@ func (h *HTMLRender) CompileTemplates() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTMLRenderer init once and returns the globally shared html renderer
|
||||
func HTMLRenderer() *HTMLRender {
|
||||
htmlRenderOnce.Do(initHTMLRenderer)
|
||||
return htmlRender
|
||||
func ReloadAllTemplates() error {
|
||||
return errors.Join(PageRendererReload(), MailRendererReload())
|
||||
}
|
||||
|
||||
func ReloadHTMLTemplates() error {
|
||||
log.Trace("Reloading HTML templates")
|
||||
if err := htmlRender.CompileTemplates(); err != nil {
|
||||
log.Error("Template error: %v\n%s", err, log.Stack(2))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initHTMLRenderer() {
|
||||
rendererType := "static"
|
||||
if !setting.IsProd {
|
||||
rendererType = "auto-reloading"
|
||||
}
|
||||
log.Debug("Creating %s HTML Renderer", rendererType)
|
||||
|
||||
htmlRender = &HTMLRender{}
|
||||
if err := htmlRender.CompileTemplates(); err != nil {
|
||||
p := &templateErrorPrettier{assets: AssetFS()}
|
||||
wrapTmplErrMsg(p.handleFuncNotDefinedError(err))
|
||||
wrapTmplErrMsg(p.handleUnexpectedOperandError(err))
|
||||
wrapTmplErrMsg(p.handleExpectedEndError(err))
|
||||
wrapTmplErrMsg(p.handleGenericTemplateError(err))
|
||||
wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err))
|
||||
}
|
||||
|
||||
if !setting.IsProd {
|
||||
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
|
||||
_ = ReloadHTMLTemplates()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func wrapTmplErrMsg(msg string) {
|
||||
if msg == "" {
|
||||
func processStartupTemplateError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if setting.IsProd {
|
||||
if setting.IsProd || setting.IsInTesting {
|
||||
// in prod mode, Gitea must have correct templates to run
|
||||
log.Fatal("Gitea can't run with template errors: %s", msg)
|
||||
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: %s", msg)
|
||||
log.Error("There are template errors but Gitea continues to run in dev mode: %v", err)
|
||||
}
|
||||
|
||||
type templateErrorPrettier struct {
|
||||
@@ -158,7 +89,7 @@ func (p *templateErrorPrettier) handleGenericTemplateError(err error) string {
|
||||
return ""
|
||||
}
|
||||
tmplName, lineStr, message := groups[1], groups[2], groups[3]
|
||||
return p.makeDetailedError(message, tmplName, lineStr, -1, "")
|
||||
return p.makeDetailedError(message, tmplName, lineStr, "", "")
|
||||
}
|
||||
|
||||
var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`)
|
||||
@@ -170,7 +101,7 @@ func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string {
|
||||
}
|
||||
tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4]
|
||||
funcName, _ = strconv.Unquote(`"` + funcName + `"`)
|
||||
return p.makeDetailedError(message, tmplName, lineStr, -1, funcName)
|
||||
return p.makeDetailedError(message, tmplName, lineStr, "", funcName)
|
||||
}
|
||||
|
||||
var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`)
|
||||
@@ -182,7 +113,7 @@ func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string {
|
||||
}
|
||||
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
|
||||
unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
|
||||
return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
|
||||
return p.makeDetailedError(message, tmplName, lineStr, "", unexpected)
|
||||
}
|
||||
|
||||
var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`)
|
||||
@@ -193,7 +124,7 @@ func (p *templateErrorPrettier) handleExpectedEndError(err error) string {
|
||||
return ""
|
||||
}
|
||||
tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
|
||||
return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
|
||||
return p.makeDetailedError(message, tmplName, lineStr, "", unexpected)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -223,20 +154,20 @@ func HandleTemplateRenderingError(err error) string {
|
||||
|
||||
const dashSeparator = "----------------------------------------------------------------------"
|
||||
|
||||
func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName string, lineNum, posNum any, target string) string {
|
||||
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 := util.ToInt64(lineNum)
|
||||
line, err := strconv.Atoi(lineNumStr)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("template error: %s, unable to parse template %q line number %q", errMsg, tmplName, lineNum)
|
||||
return fmt.Sprintf("template error: %s, unable to parse template %q line number %s", errMsg, tmplName, lineNumStr)
|
||||
}
|
||||
pos, err := util.ToInt64(posNum)
|
||||
pos, err := strconv.Atoi(util.IfZero(posNumStr, "-1"))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("template error: %s, unable to parse template %q pos number %q", errMsg, tmplName, posNum)
|
||||
return fmt.Sprintf("template error: %s, unable to parse template %q pos number %s", errMsg, tmplName, posNumStr)
|
||||
}
|
||||
detail := extractErrorLine(code, int(line), int(pos), target)
|
||||
detail := extractErrorLine(code, line, pos, target)
|
||||
|
||||
var msg string
|
||||
if pos >= 0 {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
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,
|
||||
"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
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
texttmpl "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
type MailTemplates struct {
|
||||
TemplateNames []string
|
||||
BodyTemplates *template.Template
|
||||
SubjectTemplates *texttmpl.Template
|
||||
}
|
||||
|
||||
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
|
||||
|
||||
// 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 buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error {
|
||||
// Split template into subject and body
|
||||
var subjectContent []byte
|
||||
bodyContent := content
|
||||
loc := mailSubjectSplit.FindIndex(content)
|
||||
if loc != nil {
|
||||
subjectContent = content[0:loc[0]]
|
||||
bodyContent = content[loc[1]:]
|
||||
}
|
||||
if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil {
|
||||
return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err)
|
||||
}
|
||||
if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil {
|
||||
return fmt.Errorf("failed to parse template [%s/body]: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMailTemplates provides the templates required for sending notification mails.
|
||||
func LoadMailTemplates(ctx context.Context, loadedTemplates *atomic.Pointer[MailTemplates]) {
|
||||
assetFS := AssetFS()
|
||||
refreshTemplates := func(firstRun bool) {
|
||||
var templateNames []string
|
||||
subjectTemplates := texttmpl.New("")
|
||||
bodyTemplates := template.New("")
|
||||
|
||||
subjectTemplates.Funcs(mailSubjectTextFuncMap())
|
||||
bodyTemplates.Funcs(NewFuncMap())
|
||||
|
||||
if !firstRun {
|
||||
log.Trace("Reloading mail templates")
|
||||
}
|
||||
assetPaths, err := ListMailTemplateAssetNames(assetFS)
|
||||
if err != nil {
|
||||
log.Error("Failed to list mail templates: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, assetPath := range assetPaths {
|
||||
content, layerName, err := assetFS.ReadLayeredFile(assetPath)
|
||||
if err != nil {
|
||||
log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err)
|
||||
continue
|
||||
}
|
||||
tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
|
||||
if firstRun {
|
||||
log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
|
||||
}
|
||||
templateNames = append(templateNames, tmplName)
|
||||
if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
|
||||
if firstRun {
|
||||
log.Fatal("Failed to parse mail template, err: %v", err)
|
||||
}
|
||||
log.Error("Failed to parse mail template, err: %v", err)
|
||||
}
|
||||
}
|
||||
loaded := &MailTemplates{
|
||||
TemplateNames: templateNames,
|
||||
BodyTemplates: bodyTemplates,
|
||||
SubjectTemplates: subjectTemplates,
|
||||
}
|
||||
loadedTemplates.Store(loaded)
|
||||
}
|
||||
|
||||
refreshTemplates(true)
|
||||
|
||||
if !setting.IsProd {
|
||||
// Now subjectTemplates and bodyTemplates are both synchronized
|
||||
// thus it is safe to call refresh from a different goroutine
|
||||
go assetFS.WatchLocalChanges(ctx, func() {
|
||||
refreshTemplates(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
texttemplate "text/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
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
|
||||
return r.tmplRenderer.Templates().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())
|
||||
}
|
||||
@@ -61,6 +61,10 @@ func (t *ScopedTemplate) Freeze() {
|
||||
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]
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
gitea_html "code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
type AvatarUtils struct {
|
||||
@@ -29,13 +30,9 @@ func NewAvatarUtils(ctx context.Context) *AvatarUtils {
|
||||
// AvatarHTML creates the HTML for an avatar
|
||||
func AvatarHTML(src string, size int, class, name string) template.HTML {
|
||||
sizeStr := strconv.Itoa(size)
|
||||
|
||||
if name == "" {
|
||||
name = "avatar"
|
||||
}
|
||||
|
||||
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="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`)
|
||||
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)
|
||||
|
||||
@@ -93,14 +93,14 @@ func dateTimeFormat(format string, datetime any) template.HTML {
|
||||
attrs := []string{`weekday=""`, `year="numeric"`}
|
||||
switch format {
|
||||
case "short", "long": // date only
|
||||
attrs = append(attrs, `month="`+format+`"`, `day="numeric"`)
|
||||
return template.HTML(fmt.Sprintf(`<absolute-date %s date="%s">%s</absolute-date>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
|
||||
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"`)
|
||||
return template.HTML(fmt.Sprintf(`<relative-time %s datetime="%s">%s</relative-time>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
|
||||
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 {
|
||||
|
||||
@@ -32,10 +32,10 @@ func TestDateTime(t *testing.T) {
|
||||
assert.EqualValues(t, "-", du.AbsoluteShort(timeutil.TimeStamp(0)))
|
||||
|
||||
actual := du.AbsoluteShort(refTime)
|
||||
assert.EqualValues(t, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2018-01-01T00:00:00Z">2018-01-01</absolute-date>`, actual)
|
||||
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, `<absolute-date weekday="" year="numeric" month="short" day="numeric" date="2017-12-31T19:00:00-05:00">2017-12-31</absolute-date>`, actual)
|
||||
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)
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
|
||||
"github.com/editorconfig/editorconfig-core-go/v2"
|
||||
@@ -185,3 +187,49 @@ func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
@@ -23,8 +24,10 @@ import (
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/webtheme"
|
||||
)
|
||||
|
||||
type RenderUtils struct {
|
||||
@@ -247,15 +250,52 @@ func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:rev
|
||||
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"))
|
||||
htmlCode := `<span class="labels-list">`
|
||||
var htmlCode strings.Builder
|
||||
htmlCode.WriteString(`<span class="labels-list">`)
|
||||
for _, label := range labels {
|
||||
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
|
||||
if label == nil {
|
||||
continue
|
||||
}
|
||||
link := fmt.Sprintf("%s?labels=%d", baseLink, label.ID)
|
||||
htmlCode += string(ut.RenderLabelWithLink(label, template.URL(link)))
|
||||
htmlCode.WriteString(string(ut.RenderLabelWithLink(label, template.URL(link))))
|
||||
}
|
||||
htmlCode += "</span>"
|
||||
return template.HTML(htmlCode)
|
||||
htmlCode.WriteString("</span>")
|
||||
return template.HTML(htmlCode.String())
|
||||
}
|
||||
|
||||
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) 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 "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
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 "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/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)))
|
||||
}
|
||||
@@ -10,25 +10,6 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ErrWrongSyntax represents a wrong syntax with a template
|
||||
type ErrWrongSyntax struct {
|
||||
Template string
|
||||
}
|
||||
|
||||
func (err ErrWrongSyntax) Error() string {
|
||||
return "wrong syntax found in " + err.Template
|
||||
}
|
||||
|
||||
// ErrVarMissing represents an error that no matched variable
|
||||
type ErrVarMissing struct {
|
||||
Template string
|
||||
Var string
|
||||
}
|
||||
|
||||
func (err ErrVarMissing) Error() string {
|
||||
return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -66,14 +47,14 @@ func Expand(template string, vars map[string]string) (string, error) {
|
||||
posBegin = posEnd
|
||||
if part == "{}" || part[len(part)-1] != '}' {
|
||||
// treat "{}" or "{..." as error
|
||||
err = ErrWrongSyntax{Template: template}
|
||||
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) {
|
||||
// the if key doesn't start with a letter, then we do not treat it as a var now
|
||||
// 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
|
||||
@@ -82,7 +63,7 @@ func Expand(template string, vars map[string]string) (string, error) {
|
||||
} else {
|
||||
// write the non-existing var as it is
|
||||
buf.WriteString(part)
|
||||
err = ErrVarMissing{Template: template, Var: key}
|
||||
err = fmt.Errorf("the variable %s is missing for %s", key, template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user