]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Allow partials to work as decorators
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 23 Dec 2025 13:08:19 +0000 (14:08 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 30 Dec 2025 14:58:55 +0000 (15:58 +0100)
Fixes #13193

18 files changed:
cache/dynacache/dynacache.go
common/collections/stack.go
common/collections/stack_test.go
common/maps/map.go
hugolib/alias.go
hugolib/doctree/simpletree.go
hugolib/doctree/support.go
hugolib/integrationtest_builder.go
hugolib/site.go
tpl/partials/partials.go
tpl/template.go
tpl/templates/decorator_integration_test.go [new file with mode: 0644]
tpl/templates/init.go
tpl/templates/templates.go
tpl/tplimpl/template_info.go
tpl/tplimpl/templates.go
tpl/tplimpl/templatestore.go
tpl/tplimpl/templatetransform.go

index 56070544c3fba650af5ec9f51ccc8ca5d99e8d3a..54bbf3a9b9c4286290e351207d607762de7e9573 100644 (file)
@@ -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
index 0713d196cf97cfe52cdfbfd8b85b271bf59d9907..3eb6ab515035d6565e8894bb243f5ae83cf58232 100644 (file)
@@ -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
+}
index 965d4dbc8d27bf69a6a361726045fc3d0a7cf450..4b2b1a59a7162c1b0816f533cb6a555acec020c6 100644 (file)
@@ -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)
index 3d0a15053c208a744e11f5456c558aa959ecd749..2050dac091109d535497b79eaac9af9b24fbdd98 100644 (file)
@@ -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()
index f5e7d3fad58f304345f73ab4ba6c6a30a1fee613..213a34c7975d7b047b7de86edb6e08864263da45 100644 (file)
@@ -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)
index 9db12ffd782277abb4325336878b08553ff3afae..2d5467dea937673a7966ee91c62cac4e05d60058 100644 (file)
@@ -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)}
index 7d081742f4409f24fbeb5ff692ae9d01c4ef142a..edd907ec2b955fd201d642244d1c70c94b069048 100644 (file)
@@ -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
 }
index b9aec370509ae3f507af65fccaedd98f02deaec8..4f5b299dd47a97d7dd909fa3a4b31c96941bd6b9 100644 (file)
@@ -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)
index 79d0677edd13cde37ae957c1b603eeb2d5ee8a73..93454e4295d1bee53053466bd7834c45b929611c 100644 (file)
@@ -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 {
index 022e498c540c65d64a33ae683c582fcc142045f4..58899fb77ca13050e358e53f69b00052b4e0236e 100644 (file)
@@ -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]
index bf76e99f587d52b2bf435ad5383bc65e1498014e..84873626744471bab8e5f49011242f37105afc54 100644 (file)
@@ -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 (file)
index 0000000..a441148
--- /dev/null
@@ -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" }}<b>{{ inner . }}</b>{{ end }}
+`
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html", "<b>Notice: Important!</b>")
+}
+
+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 --
+<a>{{ inner . }}</a>
+-- layouts/_partials/b.html --
+<b>{{ inner . }}</b>
+-- layouts/_partials/c.html --
+<c>{{ inner . }}</c>
+`
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html", "<a><b><c>warning</c></b></a>")
+}
+
+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 }}<a href="{{ .RelPermalink }}">{{ with partial "bold.html" . }}<span>{{ .LinkTitle }}</span>{{ end }}</a>{{ end }}
+-- layouts/_partials/ul.html --
+<ul>
+{{- range . }}
+   <li>{{ inner . }}</li>
+{{- end }}
+</ul>
+-- layouts/_partials/bold.html --
+<b>{{ inner $ }}</b>
+`
+
+       b, err := hugolib.TestE(t, files)
+
+       b.Assert(err, qt.IsNil)
+       b.AssertFileContent("public/index.html", `
+<ul>
+   <li><a href="/p1/"><b><span>Page 1</span></b></a></li>
+   <li><a href="/p2/"><b><span>Page 2</span></b></a></li>
+</ul>
+`)
+}
+
+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 --
+<b>{{ inner . }}</b>
+-- layouts/home.html --
+{{ with partial "a.html" "Hello" }}{{ . }} World0{{ end }}$
+`
+       b := hugolib.TestRunning(t, files)
+
+       b.AssertFileContent("public/index.html",
+               "<b>Hello World0</b>$",
+       )
+
+       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 --
+<b>{{ inner (printf "%s World0" .) }}</b>
+-- layouts/home.html --
+{{ with partial "a.html" "Hello" }}{{ . }}{{ end }}$
+`
+       b := hugolib.TestRunning(t, files)
+
+       b.AssertFileContent("public/index.html",
+               "<b>Hello World0</b>$",
+       )
+
+       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 --
+<b>{{ inner . }}</b>
+-- 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: <b>Hello</b>$",
+               "2: <b>World</b>$",
+       )
+}
+
+func TestDecoratorInAllTemplateTypes(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- layouts/_partials/b.html --
+<b>{{ inner . }}</b>
+-- 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: <b>hello world</b>$",
+               "shortcode: <b>hello world</b>$",
+               "link: <b>hello world</b>$</p>",
+       )
+}
+
+func TestDecoratorInAllPartialFuncNames(t *testing.T) {
+       t.Parallel()
+
+       filesTemplate := `
+-- hugo.toml --
+-- layouts/_partials/b.html --
+<b>{{ inner . }}</b>
+-- 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",
+                       "<b>hello world</b>$",
+               )
+       }
+}
+
+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 --
+<b>{{ inner . }}</b>
+-- 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")
+       }
+}
index ac62194bbdb3725eb7516cd754e3fe5840bec7d0..923f9719669a31964d5b60f92aa8d39c98d67945 100644 (file)
@@ -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{
index 92165e82470d4c8a7c55eaea357ff5baa01dd1a3..1f1eb0ad682355f6a8c72267d1942aa00242a542 100644 (file)
@@ -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 {
index 0bc807d910a6218af1fbb729b8f376c41074110f..1696cc41ed91406dce3bfe9f90508edde62b59d3 100644 (file)
@@ -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
 
index b95cde2bc99f32f6b55f8fd36787ecc70be033c1..043d4a491e21102cfc6f4041ed402d7a5e0a8cc9 100644 (file)
@@ -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.
index 748944663399c15dd37e67549ff13d3ceba963db..48f3139b93dfce352f5d5faeddecea082352d287 100644 (file)
@@ -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
                }
index eca9fdad1d91d9daa1649c8fc105d1ab26be7bc8..e4cec981387312b6564bc22d754e8f1b2993487f 100644 (file)
@@ -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