chore: move template context (#8663)

The template module now holds the **Template** context, this makes it possible for (render) function in the template module to access functions and share data between render functions.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8663
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: Lucas <sclu1034@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
Gusted 2025-07-25 11:55:15 +02:00 committed by Earl Warren
parent 61334f7982
commit d4e4a2a1e3
16 changed files with 88 additions and 70 deletions

View file

@ -0,0 +1,23 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package templates
import (
"context"
"forgejo.org/modules/translation"
)
type Context struct {
context.Context
Locale translation.Locale
AvatarUtils *AvatarUtils
Data map[string]any
}
var _ context.Context = Context{}
func NewContext(ctx context.Context) *Context {
return &Context{Context: ctx}
}

View file

@ -0,0 +1,18 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package templates
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestContext(t *testing.T) {
type ctxKey struct{}
// Test that the original context is used for its context functions.
ctx := NewContext(context.WithValue(t.Context(), ctxKey{}, "there"))
assert.Equal(t, "there", ctx.Value(ctxKey{}))
}

View file

@ -22,7 +22,6 @@ import (
"forgejo.org/modules/markup"
"forgejo.org/modules/markup/markdown"
"forgejo.org/modules/setting"
"forgejo.org/modules/translation"
"forgejo.org/modules/util"
)
@ -145,7 +144,7 @@ func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
// RenderLabel renders a label
// locale is needed due to an import cycle with our context providing the `Tr` function
func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
func RenderLabel(ctx *Context, label *issues_model.Label) template.HTML {
var (
archivedCSSClass string
textColor = util.ContrastColor(label.Color)
@ -156,7 +155,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
if label.IsArchived() {
archivedCSSClass = "archived-label"
description = locale.TrString("repo.issues.archived_label_description", description)
description = ctx.Locale.TrString("repo.issues.archived_label_description", description)
}
if labelScope == "" {
@ -246,7 +245,7 @@ func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //n
return output
}
func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, isPull bool) template.HTML {
func RenderLabels(ctx *Context, labels []*issues_model.Label, repoLink string, isPull bool) template.HTML {
htmlCode := `<span class="labels-list">`
for _, label := range labels {
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
@ -259,7 +258,7 @@ func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issu
issuesOrPull = "pulls"
}
htmlCode += fmt.Sprintf("<a href='%s/%s?labels=%d' rel='nofollow'>%s</a> ",
repoLink, issuesOrPull, label.ID, RenderLabel(ctx, locale, label))
repoLink, issuesOrPull, label.ID, RenderLabel(ctx, label))
}
htmlCode += "</span>"
return template.HTML(htmlCode)

View file

@ -223,23 +223,26 @@ func TestRenderLabels(t *testing.T) {
labelMalicious := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 11})
labelArchived := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 12})
rendered := RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false)
ctx := NewContext(t.Context())
ctx.Locale = tr
rendered := RenderLabels(ctx, []*issues_model.Label{label}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=1")
assert.Contains(t, rendered, ">label1<")
assert.Contains(t, rendered, "title='First label'")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true)
rendered = RenderLabels(ctx, []*issues_model.Label{label}, "user2/repo1", true)
assert.Contains(t, rendered, "user2/repo1/pulls?labels=1")
assert.Contains(t, rendered, ">label1<")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelScoped}, "user2/repo1", false)
rendered = RenderLabels(ctx, []*issues_model.Label{labelScoped}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=7")
assert.Contains(t, rendered, ">scope<")
assert.Contains(t, rendered, ">label1<")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelMalicious}, "user2/repo1", false)
rendered = RenderLabels(ctx, []*issues_model.Label{labelMalicious}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=11")
assert.Contains(t, rendered, "> &lt;script&gt;malicious&lt;/script&gt; <")
assert.Contains(t, rendered, ">&#39;?&amp;<")
assert.Contains(t, rendered, "title='Malicious label &#39; &lt;script&gt;malicious&lt;/script&gt;'")
rendered = RenderLabels(db.DefaultContext, tr, []*issues_model.Label{labelArchived}, "user2/repo1", false)
rendered = RenderLabels(ctx, []*issues_model.Label{labelArchived}, "user2/repo1", false)
assert.Contains(t, rendered, "user2/repo1/issues?labels=12")
assert.Contains(t, rendered, ">archived label&lt;&gt;<")
assert.Contains(t, rendered, "title='repo.issues.archived_label_description'")

View file

@ -15,7 +15,6 @@ import (
"forgejo.org/modules/templates"
"forgejo.org/modules/web/middleware"
"forgejo.org/modules/web/routing"
"forgejo.org/services/context"
)
const tplStatus500 base.TplName = "status/500"
@ -36,8 +35,8 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) {
httpcache.SetCacheControlInHeader(w.Header(), 0, "no-transform")
w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
tmplCtx := context.TemplateContext{}
tmplCtx["Locale"] = middleware.Locale(w, req)
tmplCtx := templates.NewContext(req.Context())
tmplCtx.Locale = middleware.Locale(w, req)
ctxData := middleware.GetContextData(req.Context())
// This recovery handler could be called without Gitea's web context, so we shouldn't touch that context too much.

View file

@ -41,7 +41,7 @@ type Render interface {
type Context struct {
*Base
TemplateContext TemplateContext
TemplateContext *templates.Context
Render Render
PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData`
@ -64,8 +64,6 @@ type Context struct {
Package *Package
}
type TemplateContext map[string]any
func init() {
web.RegisterResponseStatusProvider[*Context](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(WebContextKey).(*Context)
@ -98,10 +96,11 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
return ctx
}
func NewTemplateContextForWeb(ctx *Context) TemplateContext {
tmplCtx := NewTemplateContext(ctx)
tmplCtx["Locale"] = ctx.Locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
func NewTemplateContextForWeb(ctx *Context) *templates.Context {
tmplCtx := templates.NewContext(ctx)
tmplCtx.Locale = ctx.Locale
tmplCtx.AvatarUtils = templates.NewAvatarUtils(ctx)
tmplCtx.Data = ctx.Data
return tmplCtx
}

View file

@ -1,35 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"time"
)
var _ context.Context = TemplateContext(nil)
func NewTemplateContext(ctx context.Context) TemplateContext {
return TemplateContext{"_ctx": ctx}
}
func (c TemplateContext) parentContext() context.Context {
return c["_ctx"].(context.Context)
}
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
return c.parentContext().Deadline()
}
func (c TemplateContext) Done() <-chan struct{} {
return c.parentContext().Done()
}
func (c TemplateContext) Err() error {
return c.parentContext().Err()
}
func (c TemplateContext) Value(key any) any {
return c.parentContext().Value(key)
}

View file

@ -65,7 +65,7 @@
<div class="issue-card-bottom">
<div class="labels-list">
{{range .Labels}}
<a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx ctx.Locale .}}</a>
<a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx .}}</a>
{{end}}
</div>
<div class="issue-card-assignees">

View file

@ -30,7 +30,7 @@
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<div class="item issue-action tw-flex tw-justify-between" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
{{if SliceUtils.Contains $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel $.Context ctx.Locale .}}
{{if SliceUtils.Contains $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel ctx .}}
{{template "repo/issue/labels/label_archived" .}}
</div>
{{end}}

View file

@ -4,5 +4,5 @@
href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
rel="nofollow"
>
{{- RenderLabel $.Context ctx.Locale .label -}}
{{- RenderLabel ctx .label -}}
</a>

View file

@ -32,7 +32,7 @@
{{range .Labels}}
<li class="item">
<div class="label-title">
{{RenderLabel $.Context ctx.Locale .}}
{{RenderLabel ctx .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
</div>
<div class="label-issues">
@ -72,7 +72,7 @@
{{range .OrgLabels}}
<li class="item org-label">
<div class="label-title">
{{RenderLabel $.Context ctx.Locale .}}
{{RenderLabel ctx .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
</div>
<div class="label-issues">

View file

@ -21,7 +21,7 @@
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context ctx.Locale .}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel ctx .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
@ -34,7 +34,7 @@
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context ctx.Locale .}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel ctx .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a>

View file

@ -176,11 +176,11 @@
<span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}}
{{if and .AddedLabels (not .RemovedLabels)}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels ctx .AddedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{else if and (not .AddedLabels) .RemovedLabels}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels ctx .RemovedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{else}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels ctx .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels ctx .RemovedLabels $.RepoLink .Issue.IsPull) $createdStr}}
{{end}}
</span>
</div>
@ -742,11 +742,11 @@
<li>
<span class="badge">{{svg "octicon-tag" 20}}</span>
{{if and .AddedLabels (not .RemovedLabels)}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) ""}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels ctx .AddedLabels $.RepoLink .Issue.IsPull) ""}}
{{else if and (not .AddedLabels) .RemovedLabels}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels ctx .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{else}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels ctx .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels ctx .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{end}}
</li>
{{end}}

View file

@ -21,7 +21,7 @@
{{end}}
<span class="labels-list tw-ml-1">
{{range .Labels}}
<a href="?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}" rel="nofollow">{{RenderLabel $.Context ctx.Locale .}}</a>
<a href="?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}" rel="nofollow">{{RenderLabel ctx .}}</a>
{{end}}
</span>
</div>

View file

@ -42,7 +42,7 @@
{{svg "octicon-check"}}
{{end}}
{{end}}
{{RenderLabel $.Context ctx.Locale .}}
{{RenderLabel ctx .}}
<p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
{{end}}

View file

@ -1524,3 +1524,15 @@ func TestIssuePostersSearch(t *testing.T) {
assert.EqualValues(t, 1, data.Results[0].UserID)
})
}
func TestIssueTimelineLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
assert.NotContains(t, resp.Body.String(), `status-page-500`)
htmlDoc := NewHTMLParser(t, resp.Body)
filterLinks := htmlDoc.Find(".timeline .labels-list a")
assert.Equal(t, 9, filterLinks.Length())
}