From: Bjørn Erik Pedersen
Date: Tue, 23 Dec 2025 13:08:19 +0000 (+0100)
Subject: Allow partials to work as decorators
X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=7c19c196c3c1ec44826bb9f22e09d1b08830eb0c;p=brevno-suite%2Fhugo
Allow partials to work as decorators
Fixes #13193
---
diff --git a/cache/dynacache/dynacache.go b/cache/dynacache/dynacache.go
index 56070544c..54bbf3a9b 100644
--- a/cache/dynacache/dynacache.go
+++ b/cache/dynacache/dynacache.go
@@ -69,7 +69,7 @@ func New(opts Options) *Cache {
infol := opts.Log.InfoCommand("dynacache")
- evictedIdentities := collections.NewStack[KeyIdentity]()
+ evictedIdentities := collections.NewStackThreadSafe[KeyIdentity]()
onEvict := func(k, v any) {
if !opts.Watching {
@@ -129,7 +129,7 @@ type Cache struct {
partitions map[string]PartitionManager
onEvict func(k, v any)
- evictedIdentities *collections.Stack[KeyIdentity]
+ evictedIdentities *collections.StackThreadSafe[KeyIdentity]
opts Options
infol logg.LevelLogger
diff --git a/common/collections/stack.go b/common/collections/stack.go
index 0713d196c..3eb6ab515 100644
--- a/common/collections/stack.go
+++ b/common/collections/stack.go
@@ -21,24 +21,24 @@ import (
"github.com/gohugoio/hugo/common/hiter"
)
-// Stack is a simple LIFO stack that is safe for concurrent use.
-type Stack[T any] struct {
+// StackThreadSafe is a simple LIFO stack that is safe for concurrent use.
+type StackThreadSafe[T any] struct {
items []T
zero T
mu sync.RWMutex
}
-func NewStack[T any]() *Stack[T] {
- return &Stack[T]{}
+func NewStackThreadSafe[T any]() *StackThreadSafe[T] {
+ return &StackThreadSafe[T]{}
}
-func (s *Stack[T]) Push(item T) {
+func (s *StackThreadSafe[T]) Push(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = append(s.items, item)
}
-func (s *Stack[T]) Pop() (T, bool) {
+func (s *StackThreadSafe[T]) Pop() (T, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
@@ -49,7 +49,7 @@ func (s *Stack[T]) Pop() (T, bool) {
return item, true
}
-func (s *Stack[T]) Peek() (T, bool) {
+func (s *StackThreadSafe[T]) Peek() (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.items) == 0 {
@@ -58,18 +58,18 @@ func (s *Stack[T]) Peek() (T, bool) {
return s.items[len(s.items)-1], true
}
-func (s *Stack[T]) Len() int {
+func (s *StackThreadSafe[T]) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.items)
}
// All returns all items in the stack, from bottom to top.
-func (s *Stack[T]) All() iter.Seq2[int, T] {
+func (s *StackThreadSafe[T]) All() iter.Seq2[int, T] {
return hiter.Lock2(slices.All(s.items), s.mu.RLock, s.mu.RUnlock)
}
-func (s *Stack[T]) Drain() []T {
+func (s *StackThreadSafe[T]) Drain() []T {
s.mu.Lock()
defer s.mu.Unlock()
items := s.items
@@ -77,7 +77,7 @@ func (s *Stack[T]) Drain() []T {
return items
}
-func (s *Stack[T]) DrainMatching(predicate func(T) bool) []T {
+func (s *StackThreadSafe[T]) DrainMatching(predicate func(T) bool) []T {
s.mu.Lock()
defer s.mu.Unlock()
var items []T
@@ -89,3 +89,47 @@ func (s *Stack[T]) DrainMatching(predicate func(T) bool) []T {
}
return items
}
+
+// Stack is a simple LIFO stack that is not safe for concurrent use.
+type Stack[T any] struct {
+ items []T
+ zero T
+}
+
+func NewStack[T any]() *Stack[T] {
+ return &Stack[T]{}
+}
+
+func (s *Stack[T]) Push(item T) {
+ s.items = append(s.items, item)
+}
+
+func (s *Stack[T]) Pop() (T, bool) {
+ if len(s.items) == 0 {
+ return s.zero, false
+ }
+ item := s.items[len(s.items)-1]
+ s.items = s.items[:len(s.items)-1]
+ return item, true
+}
+
+func (s *Stack[T]) Peek() (T, bool) {
+ if len(s.items) == 0 {
+ return s.zero, false
+ }
+ return s.items[len(s.items)-1], true
+}
+
+func (s *Stack[T]) Len() int {
+ return len(s.items)
+}
+
+func (s *Stack[T]) All() iter.Seq2[int, T] {
+ return slices.All(s.items)
+}
+
+func (s *Stack[T]) Drain() []T {
+ items := s.items
+ s.items = nil
+ return items
+}
diff --git a/common/collections/stack_test.go b/common/collections/stack_test.go
index 965d4dbc8..4b2b1a59a 100644
--- a/common/collections/stack_test.go
+++ b/common/collections/stack_test.go
@@ -10,7 +10,7 @@ func TestNewStack(t *testing.T) {
t.Parallel()
c := qt.New(t)
- s := NewStack[int]()
+ s := NewStackThreadSafe[int]()
c.Assert(s, qt.IsNotNil)
}
@@ -19,7 +19,7 @@ func TestStackBasic(t *testing.T) {
t.Parallel()
c := qt.New(t)
- s := NewStack[int]()
+ s := NewStackThreadSafe[int]()
c.Assert(s.Len(), qt.Equals, 0)
@@ -50,7 +50,7 @@ func TestStackDrain(t *testing.T) {
t.Parallel()
c := qt.New(t)
- s := NewStack[string]()
+ s := NewStackThreadSafe[string]()
s.Push("a")
s.Push("b")
@@ -64,7 +64,7 @@ func TestStackDrainMatching(t *testing.T) {
t.Parallel()
c := qt.New(t)
- s := NewStack[int]()
+ s := NewStackThreadSafe[int]()
s.Push(1)
s.Push(2)
s.Push(3)
diff --git a/common/maps/map.go b/common/maps/map.go
index 3d0a15053..2050dac09 100644
--- a/common/maps/map.go
+++ b/common/maps/map.go
@@ -73,6 +73,18 @@ func (m *Map[K, T]) Set(key K, value T) {
m.mu.Unlock()
}
+// Delete deletes the given key from the map.
+// It returns true if the key was found and deleted, false otherwise.
+func (m *Map[K, T]) Delete(key K) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, found := m.m[key]; found {
+ delete(m.m, key)
+ return true
+ }
+ return false
+}
+
// WithWriteLock executes the given function with a write lock on the map.
func (m *Map[K, T]) WithWriteLock(f func(m map[K]T) error) error {
m.mu.Lock()
diff --git a/hugolib/alias.go b/hugolib/alias.go
index f5e7d3fad..213a34c79 100644
--- a/hugolib/alias.go
+++ b/hugolib/alias.go
@@ -29,7 +29,6 @@ import (
"github.com/gohugoio/hugo/output"
"github.com/gohugoio/hugo/publisher"
"github.com/gohugoio/hugo/resources/page"
- "github.com/gohugoio/hugo/tpl"
"github.com/gohugoio/hugo/tpl/tplimpl"
)
@@ -76,7 +75,7 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page, matrix sitesmat
p,
}
- ctx := tpl.Context.Page.Set(context.Background(), p)
+ ctx := a.ts.PrepareTopLevelRenderCtx(context.Background(), p)
buffer := new(bytes.Buffer)
err := a.ts.ExecuteWithContext(ctx, t, buffer, data)
diff --git a/hugolib/doctree/simpletree.go b/hugolib/doctree/simpletree.go
index 9db12ffd7..2d5467dea 100644
--- a/hugolib/doctree/simpletree.go
+++ b/hugolib/doctree/simpletree.go
@@ -117,6 +117,10 @@ func (tree *SimpleTree[T]) All() iter.Seq2[string, T] {
}
}
+func (tree *SimpleTree[T]) Len() int {
+ return tree.tree.Len()
+}
+
// NewSimpleThreadSafeTree creates a new SimpleTree.
func NewSimpleThreadSafeTree[T any]() *SimpleThreadSafeTree[T] {
return &SimpleThreadSafeTree[T]{tree: radix.New[T](), mu: new(sync.RWMutex)}
diff --git a/hugolib/doctree/support.go b/hugolib/doctree/support.go
index 7d081742f..edd907ec2 100644
--- a/hugolib/doctree/support.go
+++ b/hugolib/doctree/support.go
@@ -224,7 +224,7 @@ type WalkContext[T any] struct {
events []*Event[T]
hooksPostInit sync.Once
- hooksPost *collections.Stack[func() error]
+ hooksPost *collections.StackThreadSafe[func() error]
}
type eventHandlers[T any] map[string][]func(*Event[T])
@@ -261,9 +261,9 @@ func (ctx *WalkContext[T]) HandleEvents() error {
return nil
}
-func (ctx *WalkContext[T]) HooksPost() *collections.Stack[func() error] {
+func (ctx *WalkContext[T]) HooksPost() *collections.StackThreadSafe[func() error] {
ctx.hooksPostInit.Do(func() {
- ctx.hooksPost = collections.NewStack[func() error]()
+ ctx.hooksPost = collections.NewStackThreadSafe[func() error]()
})
return ctx.hooksPost
}
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index b9aec3705..4f5b299dd 100644
--- a/hugolib/integrationtest_builder.go
+++ b/hugolib/integrationtest_builder.go
@@ -19,6 +19,7 @@ import (
"testing"
"github.com/bep/logg"
+ "github.com/yuin/goldmark/util"
qt "github.com/frankban/quicktest"
"github.com/fsnotify/fsnotify"
@@ -419,7 +420,7 @@ func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...s
content := strings.TrimSpace(s.FileContent(filename))
for _, m := range matches {
- cm := qt.Commentf("File: %s Expect: %s Got: %s", filename, m, content)
+ cm := qt.Commentf("File: %s Expect:\n%s Got:\n%s\nWith Space Visuals:\n%s", filename, m, content, util.VisualizeSpaces([]byte(content)))
lines := strings.SplitSeq(m, "\n")
for match := range lines {
match = strings.TrimSpace(match)
diff --git a/hugolib/site.go b/hugolib/site.go
index 79d0677ed..93454e429 100644
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -1610,7 +1610,7 @@ func (s *Site) renderAndWritePage(statCounter *uint64, targetPath string, p *pag
of := p.outputFormat()
p.incrRenderState()
- ctx := tpl.Context.Page.Set(context.Background(), p)
+ ctx := s.TemplateStore.PrepareTopLevelRenderCtx(context.Background(), p)
ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, p)
if err := s.renderForTemplate(ctx, p.Kind(), of.Name, d, renderBuffer, templ); err != nil {
diff --git a/tpl/partials/partials.go b/tpl/partials/partials.go
index 022e498c5..58899fb77 100644
--- a/tpl/partials/partials.go
+++ b/tpl/partials/partials.go
@@ -145,6 +145,15 @@ func (ns *Namespace) lookup(name string) (*tplimpl.TemplInfo, error) {
// include is a helper function that lookups and executes the named partial.
// Returns the final template name and the rendered output.
func (ns *Namespace) doInclude(ctx context.Context, key string, templ *tplimpl.TemplInfo, dataList ...any) includeResult {
+ if templ.ParseInfo.HasPartialInner {
+ stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
+ if stack != nil {
+ if id, ok := stack.Peek(); ok {
+ // Signal that inner exists.
+ id.Bool = true
+ }
+ }
+ }
var data any
if len(dataList) > 0 {
data = dataList[0]
diff --git a/tpl/template.go b/tpl/template.go
index bf76e99f5..848736267 100644
--- a/tpl/template.go
+++ b/tpl/template.go
@@ -23,6 +23,7 @@ import (
"github.com/bep/helpers/contexthelpers"
bp "github.com/gohugoio/hugo/bufferpool"
+ "github.com/gohugoio/hugo/common/collections"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/langs"
@@ -53,6 +54,7 @@ const (
contextKeyPage
contextKeyIsInGoldmark
cntextKeyCurrentTemplateInfo
+ contextKeyPartialDecoratorIDStack
)
// Context manages values passed in the context to templates.
@@ -63,12 +65,14 @@ var Context = struct {
Page contexthelpers.ContextDispatcher[page]
IsInGoldmark contexthelpers.ContextDispatcher[bool]
CurrentTemplate contexthelpers.ContextDispatcher[*CurrentTemplateInfo]
+ PartialDecoratorIDStack contexthelpers.ContextDispatcher[*collections.Stack[*StringBool]]
}{
DependencyManagerScopedProvider: contexthelpers.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKeyDependencyManagerScopedProvider),
DependencyScope: contexthelpers.NewContextDispatcher[int](contextKeyDependencyScope),
Page: contexthelpers.NewContextDispatcher[page](contextKeyPage),
IsInGoldmark: contexthelpers.NewContextDispatcher[bool](contextKeyIsInGoldmark),
CurrentTemplate: contexthelpers.NewContextDispatcher[*CurrentTemplateInfo](cntextKeyCurrentTemplateInfo),
+ PartialDecoratorIDStack: contexthelpers.NewContextDispatcher[*collections.Stack[*StringBool]](contextKeyPartialDecoratorIDStack),
}
func init() {
@@ -81,6 +85,12 @@ func init() {
}
}
+// StringBool is a helper struct to hold a string and a bool value.
+type StringBool struct {
+ Str string
+ Bool bool
+}
+
type page interface {
IsNode() bool
}
diff --git a/tpl/templates/decorator_integration_test.go b/tpl/templates/decorator_integration_test.go
new file mode 100644
index 000000000..a441148ea
--- /dev/null
+++ b/tpl/templates/decorator_integration_test.go
@@ -0,0 +1,369 @@
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package templates_test
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestDecoratorInnerNeverCalled(t *testing.T) {
+ t.Parallel()
+
+ filesTemplate := `
+-- hugo.toml --
+disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
+-- content/p1.md --
+---
+title: "Page 1"
+---
+-- content/p2.md --
+---
+title: "Page 2"
+---
+-- layouts/_partials/cards.html --
+Start:{{ range . }}{{ PLACEHOLDER . }}{{ end }}End$
+-- layouts/home.html --
+1:${{ with partial "cards.html" (site.RegularPages) }}{{ printf "Got %T" . }}|{{ end }}$
+2:${{ with partial "cards.html" (site.RegularPages | first 0) }}{{ printf "Got %T" . }}|{{ end }}$
+`
+
+ for _, placeholder := range []string{"inner", "templates.Inner"} {
+ files := strings.ReplaceAll(filesTemplate, "PLACEHOLDER", placeholder)
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "1:$Start:Got *hugolib.pageState|Got *hugolib.pageState|End$$",
+ "2:$Start:End$$",
+ )
+
+ }
+}
+
+func TestDecoratorInlinePartialInnerNeverCalled(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
+-- content/p1.md --
+---
+title: "Page 1"
+---
+-- content/p2.md --
+---
+title: "Page 2"
+---
+-- layouts/home.html --
+${{ with partial "cards.html" (site.RegularPages) }}{{ printf "Got %T" . }}|{{ end }}$
+{{ define "_partials/cards.html" }}Start:{{ range . }}{{ inner . }}{{ end }}End${{ end }}
+`
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "$Start:Got *hugolib.pageState|Got *hugolib.pageState|End$$",
+ )
+}
+
+func TestDecoratorInlinePartial(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ["section", "taxonomy", "term", "sitemap", "rss"]
+-- layouts/home.html --
+Home.
+{{ with partial "decorate.html" "Important!" }}Notice: {{ . }}{{ end }}
+{{ define "_partials/decorate.html" }}{{ inner . }}{{ end }}
+`
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html", "Notice: Important!")
+}
+
+func TestDecoratorNestedSimple(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ["section", "taxonomy", "term", "sitemap", "rss"]
+-- layouts/home.html --
+Home.
+{{ with partial "a.html" "warning" }}{{ with partial "b.html" . }}{{ with partial "c.html" . }}{{ . }}{{ end }}{{ end }}{{ end }}
+-- layouts/_partials/a.html --
+{{ inner . }}
+-- layouts/_partials/b.html --
+{{ inner . }}
+-- layouts/_partials/c.html --
+{{ inner . }}
+`
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html", "warning")
+}
+
+func TestDecoratorNested2(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
+title = "Test title"
+-- content/p1.md --
+---
+title: "Page 1"
+---
+-- content/p2.md --
+---
+title: "Page 2"
+---
+-- layouts/page.html --
+{{ .Title }}
+-- layouts/home.html --
+{{ $pages := site.RegularPages }}
+{{ with partial "ul.html" $pages }}{{ with partial "bold.html" . }}{{ .LinkTitle }}{{ end }}{{ end }}
+-- layouts/_partials/ul.html --
+
+{{- range . }}
+ - {{ inner . }}
+{{- end }}
+
+-- layouts/_partials/bold.html --
+{{ inner $ }}
+`
+
+ b, err := hugolib.TestE(t, files)
+
+ b.Assert(err, qt.IsNil)
+ b.AssertFileContent("public/index.html", `
+
+`)
+}
+
+func TestDecoratorMultiple(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+title = "Test title"
+-- layouts/home.html --
+{{ with partial "d1.html" . }}X2{{ . }}X4{{ end }}
+-- layouts/_partials/d1.html --
+X1{{ inner "X3" }}X5
+{{ with partial "d2.html" . }}X7{{ . }}X9{{ end }}
+{{ with partial "noinner.html" "N3" }}N1{{ . }}N5{{ end }}
+X14{{ inner "X15" }}X16
+-- layouts/_partials/d2.html --
+X6{{ inner "X8" }}X10
+{{ with partial "d3.html" . }}A1{{ . }}A2{{ end }}
+X11{{ inner "X12" }}X13
+-- layouts/_partials/d3.html --
+A3{{ inner "A4" }}A5
+A6{{ inner "A7" }}A8
+-- layouts/_partials/noinner.html --
+N2{{ . }}N4
+`
+
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "X1X2X3X4X5",
+ "X6X7X8X9X10",
+ "X11X7X12X9X13",
+ "X14X2X15X4X16",
+ "A3A1A4A2A5",
+ "N1N2N3N4N5", // partial with with, but no inner.
+ )
+}
+
+func TestDecoratorEditInner(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+baseURL = "http://example.org/"
+disableLiveReload = true
+-- layouts/_partials/a.html --
+{{ inner . }}
+-- layouts/home.html --
+{{ with partial "a.html" "Hello" }}{{ . }} World0{{ end }}$
+`
+ b := hugolib.TestRunning(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "Hello World0$",
+ )
+
+ for i := range 4 {
+ b.EditFileReplaceAll("layouts/home.html", fmt.Sprintf("World%d", i), fmt.Sprintf("World%d", i+1)).Build()
+
+ b.AssertFileContent("public/index.html")
+ }
+}
+
+func TestDecoratorEditPartial(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+baseURL = "http://example.org/"
+disableLiveReload = true
+-- layouts/_partials/a.html --
+{{ inner (printf "%s World0" .) }}
+-- layouts/home.html --
+{{ with partial "a.html" "Hello" }}{{ . }}{{ end }}$
+`
+ b := hugolib.TestRunning(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "Hello World0$",
+ )
+
+ for i := range 4 {
+ b.EditFileReplaceAll("layouts/_partials/a.html", fmt.Sprintf("World%d", i), fmt.Sprintf("World%d", i+1)).Build()
+
+ b.AssertFileContent("public/index.html")
+ }
+}
+
+func TestDecoratorDuplicateInner(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- layouts/_partials/a.html --
+{{ inner . }}
+-- layouts/home.html --
+1: {{ with partial "a.html" "Hello" }}{{ . }}{{ end }}$
+2: {{ with partial "a.html" "World" }}{{ . }}{{ end }}$
+
+`
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "1: Hello$",
+ "2: World$",
+ )
+}
+
+func TestDecoratorInAllTemplateTypes(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- layouts/_partials/b.html --
+{{ inner . }}
+-- layouts/_markup/render-link.html --
+{{ with partial "b.html" "hello" }}{{ . }} world{{ end }}
+-- layouts/_shortcodes/a.html --
+{{ with partial "b.html" (.Get 0) }}{{ . }} world{{ end }}
+-- layouts/_partials/a.html --
+{{ with partial "b.html" . }}{{ . }} world{{ end }}
+-- layouts/home.html --
+partial: {{ partial "a.html" "hello" }}$
+
+{{ .Content}}
+-- content/_index.md --
+---
+title: "Home"
+---
+shortcode: {{< a "hello" >}}$
+link: [example](/some-url)$
+`
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "partial: hello world$",
+ "shortcode: hello world$",
+ "link: hello world$
",
+ )
+}
+
+func TestDecoratorInAllPartialFuncNames(t *testing.T) {
+ t.Parallel()
+
+ filesTemplate := `
+-- hugo.toml --
+-- layouts/_partials/b.html --
+{{ inner . }}
+-- layouts/home.html --
+{{ with FUNC "b.html" "hello" }}{{ . }} world{{ end }}$
+`
+
+ for _, partialFunc := range []string{"partial", "partialCached", "partials.Include", "partials.IncludeCached"} {
+ files := strings.ReplaceAll(filesTemplate, "FUNC", partialFunc)
+ b := hugolib.Test(t, files)
+
+ b.AssertFileContent("public/index.html",
+ "hello world$",
+ )
+ }
+}
+
+func TestDecoratorReturn(t *testing.T) {
+ t.Parallel()
+
+ files := `
+-- hugo.toml --
+-- layouts/_partials/add.html --
+{{ $sum := .sum }}
+{{ $sum = add $sum (inner 1) }}
+{{ $sum = add $sum (inner 2) }}
+{{ $sum = add $sum (inner 3) }}
+{{ return $sum }}
+-- layouts/home.html --
+{{ $v := dict "sum" 1 }}
+Sum: {{ with partial "add.html" $v }}
+{{ $sum := mul . 2 }}
+{{ return $sum }}
+{{ end }}$
+`
+ b := hugolib.Test(t, files)
+
+ // .sum = 1
+ // inner 1 => 2
+ // inner 2 => 4
+ // inner 3 => 6
+ // 1 + 2 + 4 + 6 = 13
+ b.AssertFileContent("public/index.html", "Sum: 13$")
+}
+
+func TestDecoratorFailOnInnerInWith(t *testing.T) {
+ t.Parallel()
+
+ filesTemplate := `
+-- hugo.toml --
+-- layouts/_partials/b.html --
+{{ inner . }}
+-- layouts/home.html --
+{{ with partial "b.html" "hello" }}
+This construct creates a loop: {{PLACEHOLDER . }}
+{{ end }}$
+`
+ for _, placeholder := range []string{"inner", "templates.Inner", " inner", "\ninner"} {
+ files := strings.ReplaceAll(filesTemplate, "PLACEHOLDER", placeholder)
+ b, err := hugolib.TestE(t, files)
+
+ b.Assert(err, qt.Not(qt.IsNil))
+ b.Assert(err.Error(), qt.Contains, "inner cannot be used inside a with block that wraps a partial decorator")
+ }
+}
diff --git a/tpl/templates/init.go b/tpl/templates/init.go
index ac62194bb..923f97196 100644
--- a/tpl/templates/init.go
+++ b/tpl/templates/init.go
@@ -18,6 +18,7 @@ import (
"github.com/gohugoio/hugo/deps"
"github.com/gohugoio/hugo/tpl/internal"
+ "github.com/gohugoio/hugo/tpl/partials"
)
const name = "templates"
@@ -29,6 +30,19 @@ func init() {
ns := &internal.TemplateFuncsNamespace{
Name: name,
Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
+ OnCreated: func(m map[string]any) {
+ LOOP:
+ for _, v := range m {
+ switch v := v.(type) {
+ case *partials.Namespace:
+ ctx.partialsNs = v
+ break LOOP
+ }
+ }
+ if ctx.partialsNs == nil {
+ panic("partialsNs namespace not found")
+ }
+ },
}
ns.AddMethodMapping(ctx.Current,
@@ -47,6 +61,23 @@ func init() {
[][2]string{},
)
+ ns.AddMethodMapping(ctx.Inner,
+ []string{"inner"},
+ [][2]string{},
+ )
+
+ // For internal use only.
+ ns.AddMethodMapping(ctx._PushPartialDecorator,
+ []string{"_pushPartialDecorator"},
+ [][2]string{},
+ )
+
+ // For internal use only.
+ ns.AddMethodMapping(ctx._PopPartialDecorator,
+ []string{"_popPartialDecorator"},
+ [][2]string{},
+ )
+
ns.AddMethodMapping(ctx.Exists,
nil,
[][2]string{
diff --git a/tpl/templates/templates.go b/tpl/templates/templates.go
index 92165e824..1f1eb0ad6 100644
--- a/tpl/templates/templates.go
+++ b/tpl/templates/templates.go
@@ -20,9 +20,12 @@ import (
"strconv"
"sync/atomic"
+ "github.com/gohugoio/hugo/tpl/tplimpl"
+
"github.com/gohugoio/hugo/common/hashing"
"github.com/gohugoio/hugo/deps"
"github.com/gohugoio/hugo/tpl"
+ "github.com/gohugoio/hugo/tpl/partials"
"github.com/mitchellh/mapstructure"
)
@@ -37,7 +40,8 @@ func New(deps *deps.Deps) *Namespace {
// Namespace provides template functions for the "templates" namespace.
type Namespace struct {
- deps *deps.Deps
+ deps *deps.Deps
+ partialsNs *partials.Namespace
}
// Exists returns whether the template with the given name exists.
@@ -70,6 +74,46 @@ type DeferOpts struct {
Data any
}
+// Inner executes the inner content of a partial decorator.
+// Note that there is only one inner block per partial decorator, but inner may be called multiple times with, typically, different data.
+func (ns *Namespace) Inner(ctx context.Context, data any) (any, error) {
+ stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
+ id, ok := stack.Peek()
+ if !ok {
+ panic("no partial decorator ID on stack")
+ }
+
+ // Signal that inner exists.
+ id.Bool = true
+
+ partialName := fmt.Sprintf("%s%s", tplimpl.PartialDecoratorPrefix, id.Str)
+
+ v, err := ns.partialsNs.Include(ctx, partialName, data)
+
+ return v, err
+}
+
+// For internal use only.
+func (ns *Namespace) _PushPartialDecorator(ctx context.Context, id string) (any, error) {
+ tpl.Context.PartialDecoratorIDStack.Get(ctx).Push(&tpl.StringBool{Str: id, Bool: false})
+ return "", nil
+}
+
+// For internal use only.
+func (ns *Namespace) _PopPartialDecorator(ctx context.Context, id string) bool {
+ stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
+ if stack == nil || stack.Len() == 0 {
+ panic("decorator stack is nil or empty")
+ }
+
+ // The stack is tied to the context, so no data race.
+ top, ok := stack.Pop()
+ if !ok || top.Str != id {
+ panic("partial decorator ID mismatch")
+ }
+ return top.Bool // return whether inner exists in the wrapped partial.
+}
+
// DoDefer defers the execution of a template block.
// For internal use only.
func (ns *Namespace) DoDefer(ctx context.Context, id string, optsv any) string {
diff --git a/tpl/tplimpl/template_info.go b/tpl/tplimpl/template_info.go
index 0bc807d91..1696cc41e 100644
--- a/tpl/tplimpl/template_info.go
+++ b/tpl/tplimpl/template_info.go
@@ -21,6 +21,9 @@ type ParseInfo struct {
// Set for shortcode templates with any {{ .Inner }}
IsInner bool
+ // Set for partial templates with any {{ inner }} or {{ templates.Inner }}
+ HasPartialInner bool
+
// Set for partials with a return statement.
HasReturn bool
diff --git a/tpl/tplimpl/templates.go b/tpl/tplimpl/templates.go
index b95cde2bc..043d4a491 100644
--- a/tpl/tplimpl/templates.go
+++ b/tpl/tplimpl/templates.go
@@ -53,6 +53,22 @@ func (s *TemplateStore) parseTemplate(ti *TemplInfo, replace bool) error {
return err
}
+func (t *templateNamespace) newBlankTemplate(ti *TemplInfo) tpl.Template {
+ if ti.D.IsPlainText {
+ tt, err := t.parseText.New(ti.Name()).Parse("")
+ if err != nil {
+ panic(err)
+ }
+ return tt
+
+ }
+ tt, err := t.parseHTML.New(ti.Name()).Parse("")
+ if err != nil {
+ panic(err)
+ }
+ return tt
+}
+
func (t *templateNamespace) doParseTemplate(ti *TemplInfo, replace bool) error {
if !ti.noBaseOf || ti.category == CategoryBaseof {
// Delay parsing until we have the base template.
diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go
index 748944663..48f3139b9 100644
--- a/tpl/tplimpl/templatestore.go
+++ b/tpl/tplimpl/templatestore.go
@@ -35,6 +35,7 @@ import (
"sync/atomic"
"time"
+ "github.com/gohugoio/hugo/common/collections"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/loggers"
@@ -489,6 +490,15 @@ func (s *TemplateStore) FindAllBaseTemplateCandidates(overlayKey string, d1 Temp
return result
}
+// PrepareTopLevelRenderCtx prepares a context for top-level rendering of a page.
+func (t *TemplateStore) PrepareTopLevelRenderCtx(ctx context.Context, p page.Page) context.Context {
+ if p != nil {
+ ctx = tpl.Context.Page.Set(ctx, p)
+ }
+ ctx = tpl.Context.PartialDecoratorIDStack.Set(ctx, collections.NewStack[*tpl.StringBool]())
+ return ctx
+}
+
func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, wr io.Writer, data any) error {
return t.ExecuteWithContextAndKey(ctx, "", ti, wr, data)
}
@@ -1262,6 +1272,7 @@ func (s *TemplateStore) insertTemplate2(
D: d,
matrix: matrix,
category: category,
+ subCategory: subCategory,
noBaseOf: category > CategoryLayout,
isLegacyMapped: isLegacyMapped,
}
@@ -1570,6 +1581,24 @@ func (s *TemplateStore) createTemplatesSnapshot() error {
return nil
}
+func (s *TemplateStore) addTransformedTemplateInsert(name string, subCategory SubCategory) (*TemplInfo, error) {
+ pi := s.opts.PathParser.Parse(files.ComponentFolderLayouts, name)
+ ti, err := s.insertTemplate(pi, nil, subCategory, true, s.treeMain)
+ if err != nil {
+ return nil, err
+ }
+ return ti, nil
+}
+
+func (s *TemplateStore) addTransformedTemplateSetTree(this *TemplInfo, root *parse.ListNode) (*parse.Tree, error) {
+ templ := s.tns.newBlankTemplate(this)
+ tree := getParseTree(templ)
+ tree.Root = root
+ this.Template = templ
+ this.state = processingStateTransformed
+ return tree, nil
+}
+
func (s *TemplateStore) parseTemplates(replace bool) error {
if err := func() error {
// Read and parse all templates.
@@ -1858,7 +1887,7 @@ func (s *TemplateStore) transformTemplates() error {
if vv.category == CategoryBaseof {
continue
}
- tctx, err := applyTemplateTransformers(vv, lookup)
+ tctx, err := applyTemplateTransformers(vv, s, lookup)
if err != nil {
return err
}
diff --git a/tpl/tplimpl/templatetransform.go b/tpl/tplimpl/templatetransform.go
index eca9fdad1..e4cec9813 100644
--- a/tpl/tplimpl/templatetransform.go
+++ b/tpl/tplimpl/templatetransform.go
@@ -3,6 +3,7 @@ package tplimpl
import (
"errors"
"fmt"
+ "regexp"
"slices"
"strings"
@@ -22,6 +23,7 @@ type templateTransformContext struct {
templateNotFound map[string]bool
deferNodes map[string]*parse.ListNode
lookupFn func(name string, in *TemplInfo) *TemplInfo
+ store *TemplateStore
// The last error encountered.
err error
@@ -53,11 +55,13 @@ func (c templateTransformContext) getIfNotVisited(name string) *TemplInfo {
func newTemplateTransformContext(
t *TemplInfo,
+ store *TemplateStore,
lookupFn func(name string, in *TemplInfo) *TemplInfo,
) *templateTransformContext {
return &templateTransformContext{
t: t,
lookupFn: lookupFn,
+ store: store,
visited: make(map[string]bool),
templateNotFound: make(map[string]bool),
deferNodes: make(map[string]*parse.ListNode),
@@ -66,28 +70,25 @@ func newTemplateTransformContext(
func applyTemplateTransformers(
t *TemplInfo,
+ store *TemplateStore,
lookupFn func(name string, in *TemplInfo) *TemplInfo,
) (*templateTransformContext, error) {
if t == nil {
return nil, errors.New("expected template, but none provided")
}
- c := newTemplateTransformContext(t, lookupFn)
+ c := newTemplateTransformContext(t, store, lookupFn)
c.t.ParseInfo = defaultParseInfo
tree := getParseTree(t.Template)
if tree == nil {
panic(fmt.Errorf("template %s not parsed", t))
}
- _, err := c.applyTransformations(tree.Root)
-
- if err == nil && c.returnNode != nil {
- // This is a partial with a return statement.
- c.t.ParseInfo.HasReturn = true
- tree.Root = c.wrapInPartialReturnWrapper(tree.Root)
+ if err := c.applyTransformationsAndSetReturnWrapper(tree); err != nil {
+ return c, fmt.Errorf("failed to transform template %q: %w", t.Name(), err)
}
- return c, err
+ return c, c.err
}
func getParseTree(templ tpl.Template) *parse.Tree {
@@ -105,11 +106,17 @@ const (
partialReturnWrapperTempl = `{{ $_hugo_dot := $ }}{{ $ := .Arg }}{{ range (slice .Arg) }}{{ $_hugo_dot.Set ("PLACEHOLDER") }}{{ end }}`
doDeferTempl = `{{ doDefer ("PLACEHOLDER1") ("PLACEHOLDER2") }}`
+
+ // _pushPartialDecorator is always falsy.
+ pushPartialDecoratorTempl = `{{ if or (_pushPartialDecorator ("PLACEHOLDER")) }}{{ end }}`
+ popPartialDecoratorTempl = `{{ if (_popPartialDecorator ("PLACEHOLDER1")) }}{{ . }}{{ else }}("PLACEHOLDER2"){{ end }}`
)
var (
partialReturnWrapper *parse.ListNode
doDefer *parse.ListNode
+ popPartialDecorator *parse.ListNode
+ pushPartialDecorator *parse.ListNode
)
func init() {
@@ -124,6 +131,18 @@ func init() {
panic(err)
}
doDefer = templ.Tree.Root
+
+ templ, err = texttemplate.New("").Funcs(texttemplate.FuncMap{"_popPartialDecorator": func(string) string { return "" }}).Parse(popPartialDecoratorTempl)
+ if err != nil {
+ panic(err)
+ }
+ popPartialDecorator = templ.Tree.Root
+
+ templ, err = texttemplate.New("").Funcs(texttemplate.FuncMap{"_pushPartialDecorator": func(string) string { return "" }}).Parse(pushPartialDecoratorTempl)
+ if err != nil {
+ panic(err)
+ }
+ pushPartialDecorator = templ.Tree.Root
}
// wrapInPartialReturnWrapper copies and modifies the parsed nodes of a
@@ -142,7 +161,20 @@ func (c *templateTransformContext) wrapInPartialReturnWrapper(n *parse.ListNode)
return wrapper
}
-// applyTransformations do 2 things:
+func (c *templateTransformContext) applyTransformationsAndSetReturnWrapper(tree *parse.Tree) error {
+ _, err := c.applyTransformations(tree.Root)
+ if err != nil {
+ return err
+ }
+ if c.returnNode != nil {
+ // This is a partial with a return statement.
+ c.t.ParseInfo.HasReturn = true
+ tree.Root = c.wrapInPartialReturnWrapper(tree.Root)
+ }
+ return nil
+}
+
+// applyTransformations does 2 things:
// 1) Parses partial return statement.
// 2) Tracks template (partial) dependencies and some other info.
func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, error) {
@@ -156,7 +188,7 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
case *parse.IfNode:
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
case *parse.WithNode:
- c.handleDefer(x)
+ c.handleWith(x)
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
case *parse.RangeNode:
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
@@ -178,7 +210,8 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
if x == nil {
return true, nil
}
- c.collectInner(x)
+ c.collectInnerInShortcode(x)
+ c.collectInnerInPartial(x)
keep := c.collectReturnNode(x)
for _, elem := range x.Args {
@@ -193,14 +226,118 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
return true, c.err
}
-func (c *templateTransformContext) handleDefer(withNode *parse.WithNode) {
+func (c *templateTransformContext) isWithPartial(args []parse.Node) bool {
+ if len(args) == 0 {
+ return false
+ }
+
+ if id1, ok := args[0].(*parse.IdentifierNode); ok && (id1.Ident == "partial" || id1.Ident == "partialCached") {
+ return true
+ }
+
+ if chain, ok := args[0].(*parse.ChainNode); ok {
+ if id2, ok := chain.Node.(*parse.IdentifierNode); !ok || (id2.Ident != "partials") {
+ return false
+ }
+ if len(chain.Field) != 1 {
+ return false
+ }
+ if chain.Field[0] != "Include" && chain.Field[0] != "IncludeCached" {
+ return false
+ }
+ return true
+ }
+ return false
+}
+
+func (c *templateTransformContext) isWithDefer(idArg parse.Node) bool {
+ id, ok := idArg.(*parse.ChainNode)
+ if !ok || len(id.Field) != 1 || id.Field[0] != "Defer" {
+ return false
+ }
+ if id2, ok := id.Node.(*parse.IdentifierNode); !ok || id2.Ident != "templates" {
+ return false
+ }
+ return true
+}
+
+// PartialDecoratorPrefix is the prefix used for internal partial decorator templates.
+const PartialDecoratorPrefix = "_internal/decorator_"
+
+var templatesInnerRe = regexp.MustCompile(`{{\s*(templates\.Inner\b|inner\b)`)
+
+func (c *templateTransformContext) handleWithPartial(withNode *parse.WithNode) {
+ withNodeInnerString := withNode.List.String()
+ if templatesInnerRe.MatchString(withNodeInnerString) {
+ c.err = fmt.Errorf("inner cannot be used inside a with block that wraps a partial decorator")
+ return
+ }
+ innerHash := hashing.XxHashFromStringHexEncoded(c.t.Name() + withNodeInnerString)
+ internalPartialName := fmt.Sprintf("_partials/%s%s", PartialDecoratorPrefix, innerHash)
+
+ if c.lookupFn(internalPartialName, c.t) == nil {
+ innerCopy := withNode.List.CopyList()
+ ti, err := c.store.addTransformedTemplateInsert(internalPartialName, SubCategoryInline)
+ if err != nil {
+ c.err = fmt.Errorf("failed to create internal partial decorator template %q: %w", internalPartialName, err)
+ return
+ }
+ if ti == nil {
+ c.err = fmt.Errorf("failed to find internal partial decorator template %q after insertion", internalPartialName)
+ return
+ }
+
+ cc := newTemplateTransformContext(ti, c.store, c.lookupFn)
+
+ tree, err := c.store.addTransformedTemplateSetTree(ti, innerCopy)
+ if err != nil {
+ c.err = fmt.Errorf("failed to add internal partial decorator template %q: %w", internalPartialName, err)
+ return
+ }
+
+ if err := cc.applyTransformationsAndSetReturnWrapper(tree); err != nil {
+ c.err = fmt.Errorf("failed to transform internal partial decorator template %q: %w", internalPartialName, err)
+ return
+ }
+
+ }
+
+ newInner := popPartialDecorator.CopyList()
+ ifNode := newInner.Nodes[0].(*parse.IfNode)
+
+ placeholderPipe := ifNode.Pipe.Cmds[0].Args[0].(*parse.PipeNode)
+
+ // Set PLACEHOLDER1 to the unique ID for this partial decorator.
+ sn1 := placeholderPipe.Cmds[0].Args[1].(*parse.PipeNode).Cmds[0].Args[0].(*parse.StringNode)
+ sn1.Text = innerHash
+ sn1.Quoted = fmt.Sprintf("%q", sn1.Text)
+
+ ifNode.ElseList = withNode.List.CopyList()
+
+ newPipe := pushPartialDecorator.CopyList()
+ orNode := newPipe.Nodes[0].(*parse.IfNode)
+ setContext := orNode.Pipe.Cmds[0].Args[1]
+ // Replace PLACEHOLDER with the unique ID for this partial decorator.
+ sn2 := setContext.(*parse.PipeNode).Cmds[0].Args[1].(*parse.PipeNode).Cmds[0].Args[0].(*parse.StringNode)
+ sn2.Text = innerHash
+ sn2.Quoted = fmt.Sprintf("%q", sn2.Text)
+ withNode.Pipe.Cmds = append(orNode.Pipe.Cmds, withNode.Pipe.Cmds...)
+
+ withNode.List = newInner
+}
+
+func (c *templateTransformContext) handleWith(withNode *parse.WithNode) {
if len(withNode.Pipe.Cmds) != 1 {
return
}
- cmd := withNode.Pipe.Cmds[0]
- if len(cmd.Args) != 1 {
+
+ if c.isWithPartial(withNode.Pipe.Cmds[0].Args) {
+ c.handleWithPartial(withNode)
return
}
+
+ cmd := withNode.Pipe.Cmds[0]
+
idArg := cmd.Args[0]
p, ok := idArg.(*parse.PipeNode)
@@ -220,11 +357,7 @@ func (c *templateTransformContext) handleDefer(withNode *parse.WithNode) {
idArg = cmd.Args[0]
- id, ok := idArg.(*parse.ChainNode)
- if !ok || len(id.Field) != 1 || id.Field[0] != "Defer" {
- return
- }
- if id2, ok := id.Node.(*parse.IdentifierNode); !ok || id2.Ident != "templates" {
+ if !c.isWithDefer(idArg) {
return
}
@@ -304,9 +437,9 @@ func (c *templateTransformContext) collectConfig(n *parse.PipeNode) {
}
}
-// collectInner determines if the given CommandNode represents a
+// collectInnerInShortcode determines if the given CommandNode represents a
// shortcode call to its .Inner.
-func (c *templateTransformContext) collectInner(n *parse.CommandNode) {
+func (c *templateTransformContext) collectInnerInShortcode(n *parse.CommandNode) {
if c.t.category != CategoryShortcode {
return
}
@@ -330,6 +463,29 @@ func (c *templateTransformContext) collectInner(n *parse.CommandNode) {
}
}
+func (c *templateTransformContext) collectInnerInPartial(n *parse.CommandNode) {
+ if c.t.category != CategoryPartial {
+ return
+ }
+
+ if c.t.ParseInfo.HasPartialInner || len(n.Args) == 0 {
+ return
+ }
+
+ switch v := n.Args[0].(type) {
+ case *parse.IdentifierNode:
+ if v.Ident == "inner" {
+ c.t.ParseInfo.HasPartialInner = true
+ }
+ case *parse.ChainNode:
+ if v.Field[0] == "Inner" {
+ if id, ok := v.Node.(*parse.IdentifierNode); ok && id.Ident == "templates" {
+ c.t.ParseInfo.HasPartialInner = true
+ }
+ }
+ }
+}
+
func (c *templateTransformContext) collectReturnNode(n *parse.CommandNode) bool {
if c.t.category != CategoryPartial || c.returnNode != nil {
return true