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 -- + +-- 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