Repository: gohugoio/hugo
Pull request #13608 (2025-04-14 10:47:26)

tpl: Misc fixes
- **tpl: Make {{ template "partials/foo.html" . }} work in older setups**
- **tpl: Add proper file context to template parse errors**
- **tpl: Fix the case for a shortcode in a nested folder only**
- **tpl: Avoid panic on nonsensical return construct**
- **tpl: Fix issue with partials without suffix**



Patch:
diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go
index 3ac0940e2..cc8a145d9 100644
--- a/hugolib/shortcode.go
+++ b/hugolib/shortcode.go
@@ -677,13 +677,7 @@ Loop:
 
 			// Used to check if the template expects inner content,
 			// so just pick one arbitrarily with the same name.
-			q := tplimpl.TemplateQuery{
-				Path:     "",
-				Name:     sc.name,
-				Category: tplimpl.CategoryShortcode,
-				Consider: nil,
-			}
-			templ := s.s.TemplateStore.LookupShortcode(q)
+			templ := s.s.TemplateStore.LookupShortcodeByName(sc.name)
 			if templ == nil {
 				return nil, fmt.Errorf("%s: template for shortcode %q not found", errorPrefix, sc.name)
 			}
diff --git a/tpl/tplimpl/templates.go b/tpl/tplimpl/templates.go
index 4c3ad3be1..19de48e38 100644
--- a/tpl/tplimpl/templates.go
+++ b/tpl/tplimpl/templates.go
@@ -44,7 +44,16 @@ var embeddedTemplatesAliases = map[string][]string{
 	"_shortcodes/twitter.html": {"_shortcodes/tweet.html"},
 }
 
-func (t *templateNamespace) parseTemplate(ti *TemplInfo) error {
+func (s *TemplateStore) parseTemplate(ti *TemplInfo) error {
+	err := s.tns.doParseTemplate(ti)
+	if err != nil {
+		return s.addFileContext(ti, "parse of template failed", err)
+	}
+
+	return err
+}
+
+func (t *templateNamespace) doParseTemplate(ti *TemplInfo) error {
 	if !ti.noBaseOf || ti.category == CategoryBaseof {
 		// Delay parsing until we have the base template.
 		return nil
@@ -99,7 +108,14 @@ func (t *templateNamespace) parseTemplate(ti *TemplInfo) error {
 					return err
 				}
 			}
+		}
 
+		// Issue #13599.
+		if ti.category == CategoryPartial && ti.Fi != nil && ti.Fi.Meta().PathInfo.Section() == "partials" {
+			aliasName := strings.TrimPrefix(name, "_")
+			if _, err := prototype.AddParseTree(aliasName, templ.(*htmltemplate.Template).Tree); err != nil {
+				return err
+			}
 		}
 	}
 
diff --git a/tpl/tplimpl/templatetransform.go b/tpl/tplimpl/templatetransform.go
index cba4c6584..eca9fdad1 100644
--- a/tpl/tplimpl/templatetransform.go
+++ b/tpl/tplimpl/templatetransform.go
@@ -175,6 +175,9 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
 		}
 
 	case *parse.CommandNode:
+		if x == nil {
+			return true, nil
+		}
 		c.collectInner(x)
 		keep := c.collectReturnNode(x)
 


Test patch:
diff --git a/tpl/templates/templates_integration_test.go b/tpl/templates/templates_integration_test.go
index 93922b4c4..d16333ed4 100644
--- a/tpl/templates/templates_integration_test.go
+++ b/tpl/templates/templates_integration_test.go
@@ -1,4 +1,4 @@
-// Copyright 2024 The Hugo Authors. All rights reserved.
+// 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.
@@ -17,6 +17,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	qt "github.com/frankban/quicktest"
 	"github.com/gohugoio/hugo/hugolib"
 )
 
@@ -229,3 +230,72 @@ layouts/section/section.html
 	b := hugolib.Test(t, files)
 	b.AssertFileContent("public/mysection/index.html", "layouts/section/section.html")
 }
+
+func TestErrorMessageParseError(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/home.html --
+Line 1.
+Line 2. {{ foo }} <- this func does not exist.
+Line 3.
+`
+
+	b, err := hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`"/layouts/home.html:2:1": parse of template failed: template: home.html:2: function "foo" not defined`))
+}
+
+func TestErrorMessageExecuteError(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/home.html --
+Line 1.
+Line 2. {{ .Foo }} <- this method does not exist.
+Line 3.
+`
+
+	b, err := hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.Assert(err.Error(), qt.Contains, filepath.FromSlash(` "/layouts/home.html:2:11": execute of template failed`))
+}
+
+func TestPartialReturnPanicIssue13600(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/home.html --
+Partial: {{ partial "p1.html" . }}
+-- layouts/_partials/p1.html --
+P1.
+{{ return ( delimit . ", " ) | string }}
+`
+
+	b, err := hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.Assert(err.Error(), qt.Contains, "wrong number of args for string: want 1 got 0")
+}
+
+func TestPartialWithoutSuffixIssue13601(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/home.html --
+P1: {{ partial "p1" . }}
+P2: {{ partial "p2" . }}
+-- layouts/_partials/p1 --
+P1.
+-- layouts/_partials/p2 --
+P2.
+{{ return "foo bar" }}
+
+`
+
+	b := hugolib.Test(t, files)
+	b.AssertFileContent("public/index.html", "P1: P1.\nP2: foo bar")
+}
diff --git a/tpl/tplimpl/legacy_integration_test.go b/tpl/tplimpl/legacy_integration_test.go
new file mode 100644
index 000000000..a96e35fca
--- /dev/null
+++ b/tpl/tplimpl/legacy_integration_test.go
@@ -0,0 +1,38 @@
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Portions Copyright The Go Authors.
+
+// 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 tplimpl_test
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestLegacyPartialIssue13599(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/partials/mypartial.html --
+Mypartial.
+-- layouts/_default/index.html --
+mypartial:   {{ template "partials/mypartial.html" . }}
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "Mypartial.")
+}
diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go
index 8483b7df0..bb58c9083 100644
--- a/tpl/tplimpl/templatestore.go
+++ b/tpl/tplimpl/templatestore.go
@@ -1,3 +1,18 @@
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Portions Copyright The Go Authors.
+
+// 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 tplimpl
 
 import (
@@ -107,6 +122,7 @@ func NewStore(opts StoreOptions, siteOpts SiteOptions) (*TemplateStore, error) {
 		treeMain:            doctree.NewSimpleTree[map[nodeKey]*TemplInfo](),
 		treeShortcodes:      doctree.NewSimpleTree[map[string]map[TemplateDescriptor]*TemplInfo](),
 		templatesByPath:     maps.NewCache[string, *TemplInfo](),
+		shortcodesByName:    maps.NewCache[string, *TemplInfo](),
 		cacheLookupPartials: maps.NewCache[string, *TemplInfo](),
 
 		// Note that the funcs passed below is just for name validation.
@@ -404,9 +420,10 @@ type TemplateStore struct {
 	siteOpts   SiteOptions
 	htmlFormat output.Format
 
-	treeMain        *doctree.SimpleTree[map[nodeKey]*TemplInfo]
-	treeShortcodes  *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo]
-	templatesByPath *maps.Cache[string, *TemplInfo]
+	treeMain         *doctree.SimpleTree[map[nodeKey]*TemplInfo]
+	treeShortcodes   *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo]
+	templatesByPath  *maps.Cache[string, *TemplInfo]
+	shortcodesByName *maps.Cache[string, *TemplInfo]
 
 	dh descriptorHandler
 
@@ -480,7 +497,7 @@ func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, w
 
 	execErr := t.storeSite.executer.ExecuteWithContext(ctx, ti, wr, data)
 	if execErr != nil {
-		return t.addFileContext(ti, execErr)
+		return t.addFileContext(ti, "execute of template failed", execErr)
 	}
 	return nil
 }
@@ -561,6 +578,15 @@ func (s *TemplateStore) LookupPartial(pth string) *TemplInfo {
 	return ti
 }
 
+func (s *TemplateStore) LookupShortcodeByName(name string) *TemplInfo {
+	name = strings.ToLower(name)
+	ti, _ := s.shortcodesByName.Get(name)
+	if ti == nil {
+		return nil
+	}
+	return ti
+}
+
 func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo {
 	q.init()
 	k1 := s.key(q.Path)
@@ -807,7 +833,7 @@ func (t *TemplateStore) addDeferredTemplate(owner *TemplInfo, name string, n *pa
 	return nil
 }
 
-func (s *TemplateStore) addFileContext(ti *TemplInfo, inerr error) error {
+func (s *TemplateStore) addFileContext(ti *TemplInfo, what string, inerr error) error {
 	if ti.Fi == nil {
 		return inerr
 	}
@@ -839,25 +865,27 @@ func (s *TemplateStore) addFileContext(ti *TemplInfo, inerr error) error {
 		fe := herrors.NewFileErrorFromName(inErr, fi.Meta().Filename)
 		fe.UpdateContent(f, lineMatcher)
 
-		if !fe.ErrorContext().Position.IsValid() {
-			return inErr, false
-		}
-		return fe, true
+		return fe, fe.ErrorContext().Position.IsValid()
 	}
 
-	inerr = fmt.Errorf("execute of template failed: %w", inerr)
+	inerr = fmt.Errorf("%s: %w", what, inerr)
 
-	if err, ok := checkFilename(ti.Fi, inerr); ok {
-		return err
+	var (
+		currentErr error
+		ok         bool
+	)
+
+	if currentErr, ok = checkFilename(ti.Fi, inerr); ok {
+		return currentErr
 	}
 
 	if ti.base != nil {
-		if err, ok := checkFilename(ti.base.Fi, inerr); ok {
-			return err
+		if currentErr, ok = checkFilename(ti.base.Fi, inerr); ok {
+			return currentErr
 		}
 	}
 
-	return inerr
+	return currentErr
 }
 
 func (s *TemplateStore) extractIdentifiers(line string) []string {
@@ -885,7 +913,7 @@ func (s *TemplateStore) extractInlinePartials() error {
 			name := templ.Name()
 			if !paths.HasExt(name) {
 				// Assume HTML. This in line with how the lookup works.
-				name = name + ".html"
+				name = name + s.htmlFormat.MediaType.FirstSuffix.FullSuffix
 			}
 			if !strings.HasPrefix(name, "_") {
 				name = "_" + name
@@ -1022,6 +1050,7 @@ func (s *TemplateStore) insertShortcode(pi *paths.Path, fi hugofs.FileMetaInfo,
 
 	m1[d] = ti
 
+	s.shortcodesByName.Set(k2, ti)
 	s.setTemplateByPath(pi.Path(), ti)
 
 	if fi != nil {
@@ -1063,6 +1092,12 @@ func (s *TemplateStore) insertTemplate2(
 		panic("category not set")
 	}
 
+	if category == CategoryPartial && d.OutputFormat == "" && d.MediaType == "" {
+		// See issue #13601.
+		d.OutputFormat = s.htmlFormat.Name
+		d.MediaType = s.htmlFormat.MediaType.Type
+	}
+
 	m := tree.Get(key)
 	nk := nodeKey{c: category, d: d}
 
@@ -1374,7 +1409,7 @@ func (s *TemplateStore) parseTemplates() error {
 				if vv.state == processingStateTransformed {
 					continue
 				}
-				if err := s.tns.parseTemplate(vv); err != nil {
+				if err := s.parseTemplate(vv); err != nil {
 					return err
 				}
 			}
@@ -1394,7 +1429,7 @@ func (s *TemplateStore) parseTemplates() error {
 						// The regular expression used to detect if a template needs a base template has some
 						// rare false positives. Assume we don't need one.
 						vv.noBaseOf = true
-						if err := s.tns.parseTemplate(vv); err != nil {
+						if err := s.parseTemplate(vv); err != nil {
 							return err
 						}
 						continue
@@ -1423,7 +1458,7 @@ func (s *TemplateStore) parseTemplates() error {
 				if vvv.state == processingStateTransformed {
 					continue
 				}
-				if err := s.tns.parseTemplate(vvv); err != nil {
+				if err := s.parseTemplate(vvv); err != nil {
 					return err
 				}
 			}
@@ -1690,6 +1725,7 @@ func (s *TemplateStore) transformTemplates() error {
 			continue
 		}
 		if !vv.noBaseOf {
+			// TODO(bep) I don't think this branch is ever called.
 			for vvv := range vv.BaseVariantsSeq() {
 				tctx, err := applyTemplateTransformers(vvv.Template, lookup)
 				if err != nil {
diff --git a/tpl/tplimpl/templatestore_integration_test.go b/tpl/tplimpl/templatestore_integration_test.go
index 4644c9639..b302f5bb3 100644
--- a/tpl/tplimpl/templatestore_integration_test.go
+++ b/tpl/tplimpl/templatestore_integration_test.go
@@ -1127,6 +1127,28 @@ single.html
 	b.AssertFileContent("public/s3/index.html", "single.html") // fail
 }
 
+func TestIssue13605(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ['home','rss','section','sitemap','taxonomy','term']
+-- content/s1/p1.md --
+---
+title: p1
+---
+{{< sc >}}
+-- layouts/s1/_shortcodes/sc.html --
+layouts/s1/_shortcodes/sc.html
+-- layouts/single.html --
+{{ .Content }}
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/s1/p1/index.html", "layouts/s1/_shortcodes/sc.html")
+}
+
 func TestSkipDotFiles(t *testing.T) {
 	t.Parallel()
 
diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go
index 8b80d5b60..b62898923 100644
--- a/tpl/tplimpl/tplimpl_integration_test.go
+++ b/tpl/tplimpl/tplimpl_integration_test.go
@@ -1,3 +1,18 @@
+// Copyright 2025 The Hugo Authors. All rights reserved.
+//
+// Portions Copyright The Go Authors.
+
+// 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 tplimpl_test
 
 import (