Repository: gohugoio/hugo
Pull request #13716 (2025-05-16 09:44:37)

Misc fixes
- **tpl: Fix theme overrides when theme has old layout setup (e.g. _default)**
- **Fix**



Patch:
diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go
index cc8a145d9..56bf1ff9e 100644
--- a/hugolib/shortcode.go
+++ b/hugolib/shortcode.go
@@ -398,6 +398,10 @@ func doRenderShortcode(
 			return true
 		}
 		base, layoutDescriptor := po.GetInternalTemplateBasePathAndDescriptor()
+
+		// With shortcodes/mymarkdown.md (only), this allows {{% mymarkdown %}} when rendering HTML,
+		// but will not resolve any template when doing {{< mymarkdown >}}.
+		layoutDescriptor.AlwaysAllowPlainText = sc.doMarkup
 		q := tplimpl.TemplateQuery{
 			Path:     base,
 			Name:     sc.name,
@@ -405,10 +409,9 @@ func doRenderShortcode(
 			Desc:     layoutDescriptor,
 			Consider: include,
 		}
-		v := s.TemplateStore.LookupShortcode(q)
+		v, err := s.TemplateStore.LookupShortcode(q)
 		if v == nil {
-			s.Log.Errorf("Unable to locate template for shortcode %q in page %q", sc.name, p.File().Path())
-			return zeroShortcode, nil
+			return zeroShortcode, err
 		}
 		tmpl = v
 		hasVariants = hasVariants || len(ofCount) > 1
diff --git a/tpl/tplimpl/templatedescriptor.go b/tpl/tplimpl/templatedescriptor.go
index ea47afc88..fd86f15fa 100644
--- a/tpl/tplimpl/templatedescriptor.go
+++ b/tpl/tplimpl/templatedescriptor.go
@@ -37,6 +37,7 @@ type TemplateDescriptor struct {
 	// Misc.
 	LayoutFromUserMustMatch bool // If set, we only look for the exact layout.
 	IsPlainText             bool // Whether this is a plain text template.
+	AlwaysAllowPlainText    bool // Whether to e.g. allow plain text templates to be rendered in HTML.
 }
 
 func (d *TemplateDescriptor) normalizeFromFile() {
@@ -64,7 +65,7 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool
 		return weightNoMatch
 	}
 
-	w := this.doCompare(category, isEmbedded, s.opts.DefaultContentLanguage, other)
+	w := this.doCompare(category, s.opts.DefaultContentLanguage, other)
 
 	if w.w1 <= 0 {
 		if category == CategoryMarkup && (this.Variant1 == other.Variant1) && (this.Variant2 == other.Variant2 || this.Variant2 != "" && other.Variant2 == "") {
@@ -74,7 +75,12 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool
 			}
 
 			w.w1 = 1
-			return w
+		}
+
+		if category == CategoryShortcode {
+			if (this.IsPlainText == other.IsPlainText || !other.IsPlainText) || this.AlwaysAllowPlainText {
+				w.w1 = 1
+			}
 		}
 	}
 
@@ -82,13 +88,16 @@ func (s descriptorHandler) compareDescriptors(category Category, isEmbedded bool
 }
 
 //lint:ignore ST1006 this vs other makes it easier to reason about.
-func (this TemplateDescriptor) doCompare(category Category, isEmbedded bool, defaultContentLanguage string, other TemplateDescriptor) weight {
+func (this TemplateDescriptor) doCompare(category Category, defaultContentLanguage string, other TemplateDescriptor) weight {
 	w := weightNoMatch
 
-	// HTML in plain text is OK, but not the other way around.
-	if other.IsPlainText && !this.IsPlainText {
-		return w
+	if !this.AlwaysAllowPlainText {
+		// HTML in plain text is OK, but not the other way around.
+		if other.IsPlainText && !this.IsPlainText {
+			return w
+		}
 	}
+
 	if other.Kind != "" && other.Kind != this.Kind {
 		return w
 	}


Test patch:
diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go
index f1d90e22e..a1f12e77a 100644
--- a/hugolib/shortcode_test.go
+++ b/hugolib/shortcode_test.go
@@ -918,7 +918,7 @@ func TestShortcodeMarkdownOutputFormat(t *testing.T) {
 ---
 title: "p1"
 ---
-{{< foo >}}
+{{% foo %}}
 # The below would have failed using the HTML template parser.
 -- layouts/shortcodes/foo.md --
 §§§
@@ -930,9 +930,7 @@ title: "p1"
 
 	b := Test(t, files)
 
-	b.AssertFileContent("public/p1/index.html", `
-<x
-	`)
+	b.AssertFileContent("public/p1/index.html", "<code>&lt;x")
 }
 
 func TestShortcodePreserveIndentation(t *testing.T) {
diff --git a/tpl/tplimpl/shortcodes_integration_test.go b/tpl/tplimpl/shortcodes_integration_test.go
index 838dc16d7..e884c9a8d 100644
--- a/tpl/tplimpl/shortcodes_integration_test.go
+++ b/tpl/tplimpl/shortcodes_integration_test.go
@@ -17,6 +17,7 @@ import (
 	"strings"
 	"testing"
 
+	qt "github.com/frankban/quicktest"
 	"github.com/gohugoio/hugo/htesting/hqt"
 	"github.com/gohugoio/hugo/hugolib"
 )
@@ -696,3 +697,63 @@ title: p2
 	b.AssertFileContent("public/p1/index.html", "78eb19b5c6f3768f")
 	b.AssertFileContent("public/p2/index.html", "a6db910a9cf54bc1")
 }
+
+func TestShortcodePlainTextVsHTMLTemplateIssue13698(t *testing.T) {
+	t.Parallel()
+
+	filesTemplate := `
+-- hugo.toml --
+markup.goldmark.renderer.unsafe = true
+-- layouts/all.html --
+Content: {{ .Content }}|
+-- layouts/_shortcodes/mymarkdown.md --
+<div>Foo bar</div>
+-- content/p1.md --
+---
+title: p1
+---
+## A shortcode
+
+SHORTCODE
+
+`
+
+	files := strings.ReplaceAll(filesTemplate, "SHORTCODE", "{{% mymarkdown %}}")
+	b := hugolib.Test(t, files)
+	b.AssertFileContent("public/p1/index.html", "<div>Foo bar</div>")
+
+	files = strings.ReplaceAll(filesTemplate, "SHORTCODE", "{{< mymarkdown >}}")
+
+	var err error
+	b, err = hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.Assert(err.Error(), qt.Contains, `no compatible template found for shortcode "mymarkdown" in [/_shortcodes/mymarkdown.md]; note that to use plain text template shortcodes in HTML you need to use the shortcode {{% delimiter`)
+}
+
+func TestShortcodeOnlyLanguageInBaseIssue13699(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+baseURL = 'https://example.org/'
+[languages]
+[languages.en]
+weight = 1
+disableLanguages = ['de']
+[languages.de]
+weight = 2
+-- layouts/_shortcodes/de.html --
+de.html
+-- layouts/all.html --
+{{ .Content }}
+-- content/_index.md --
+---
+title: home
+---
+{{< de >}}
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "de.html")
+}
diff --git a/tpl/tplimpl/templatestore.go b/tpl/tplimpl/templatestore.go
index 53880eb33..2ea337274 100644
--- a/tpl/tplimpl/templatestore.go
+++ b/tpl/tplimpl/templatestore.go
@@ -19,6 +19,7 @@ import (
 	"bytes"
 	"context"
 	"embed"
+	"errors"
 	"fmt"
 	"io"
 	"io/fs"
@@ -608,7 +609,7 @@ func (s *TemplateStore) LookupShortcodeByName(name string) *TemplInfo {
 	return ti
 }
 
-func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo {
+func (s *TemplateStore) LookupShortcode(q TemplateQuery) (*TemplInfo, error) {
 	q.init()
 	k1 := s.key(q.Path)
 
@@ -630,13 +631,15 @@ func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo {
 		}
 
 		for k, vv := range v {
+			best.candidates = append(best.candidates, vv)
 			if !q.Consider(vv) {
 				continue
 			}
 
 			weight := s.dh.compareDescriptors(q.Category, vv.subCategory == SubCategoryEmbedded, q.Desc, k)
 			weight.distance = distance
-			if best.isBetter(weight, vv) {
+			isBetter := best.isBetter(weight, vv)
+			if isBetter {
 				best.updateValues(weight, k2, k, vv)
 			}
 		}
@@ -644,8 +647,21 @@ func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo {
 		return false, nil
 	})
 
-	// Any match will do.
-	return best.templ
+	if best.w.w1 <= 0 {
+		var err error
+		if s := best.candidatesAsStringSlice(); s != nil {
+			msg := fmt.Sprintf("no compatible template found for shortcode %q in %s", q.Name, s)
+			if !q.Desc.IsPlainText {
+				msg += "; note that to use plain text template shortcodes in HTML you need to use the shortcode {{% delimiter"
+			}
+			err = errors.New(msg)
+		} else {
+			err = fmt.Errorf("no template found for shortcode %q", q.Name)
+		}
+		return nil, err
+	}
+
+	return best.templ, nil
 }
 
 // PrintDebug is for testing/debugging only.
@@ -1133,12 +1149,21 @@ func (s *TemplateStore) insertTemplate2(
 		tree.Insert(key, m)
 	}
 
-	if !replace {
-		if v, found := m[nk]; found {
-			if len(pi.Identifiers()) >= len(v.PathInfo.Identifiers()) {
-				// e.g. /pages/home.foo.html and  /pages/home.html where foo may be a valid language name in another site.
-				return nil, nil
-			}
+	nkExisting, existingFound := m[nk]
+	if !replace && existingFound && fi != nil && nkExisting.Fi != nil {
+		// See issue #13715.
+		// We do the merge on the file system level, but from Hugo v0.146.0 we have a situation where
+		// the project may well have a different layouts layout compared to the theme(s) it uses.
+		// We could possibly have fixed that on a lower (file system) level, but since this is just
+		// a temporary situation (until all projects are updated),
+		// do a replace here if the file comes from higher up in the module chain.
+		replace = fi.Meta().ModuleOrdinal < nkExisting.Fi.Meta().ModuleOrdinal
+	}
+
+	if !replace && existingFound {
+		if len(pi.Identifiers()) >= len(nkExisting.PathInfo.Identifiers()) {
+			// e.g. /pages/home.foo.html and  /pages/home.html where foo may be a valid language name in another site.
+			return nil, nil
 		}
 	}
 
@@ -1808,10 +1833,11 @@ type TextTemplatHandler interface {
 }
 
 type bestMatch struct {
-	templ *TemplInfo
-	desc  TemplateDescriptor
-	w     weight
-	key   string
+	templ      *TemplInfo
+	desc       TemplateDescriptor
+	w          weight
+	key        string
+	candidates []*TemplInfo
 
 	// settings.
 	defaultOutputformat string
@@ -1822,6 +1848,18 @@ func (best *bestMatch) reset() {
 	best.w = weight{}
 	best.desc = TemplateDescriptor{}
 	best.key = ""
+	best.candidates = nil
+}
+
+func (best *bestMatch) candidatesAsStringSlice() []string {
+	if len(best.candidates) == 0 {
+		return nil
+	}
+	candidates := make([]string, len(best.candidates))
+	for i, v := range best.candidates {
+		candidates[i] = v.PathInfo.Path()
+	}
+	return candidates
 }
 
 func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool {
@@ -1831,7 +1869,6 @@ func (best *bestMatch) isBetter(w weight, ti *TemplInfo) bool {
 	}
 
 	if w.w1 <= 0 {
-
 		if best.w.w1 <= 0 {
 			return ti.PathInfo.Path() < best.templ.PathInfo.Path()
 		}
diff --git a/tpl/tplimpl/templatestore_integration_test.go b/tpl/tplimpl/templatestore_integration_test.go
index 75ec0376f..0b3ce7a56 100644
--- a/tpl/tplimpl/templatestore_integration_test.go
+++ b/tpl/tplimpl/templatestore_integration_test.go
@@ -920,6 +920,26 @@ func TestPartialHTML(t *testing.T) {
 	b.AssertFileContent("public/index.html", "<link rel=\"stylesheet\" href=\"/css/style.css\">")
 }
 
+func TestPartialPlainTextInHTML(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/all.html --
+<html>
+<head>
+{{ partial "mypartial.txt" . }}
+</head>
+</html>
+-- layouts/partials/mypartial.txt --
+My <div>partial</div>.
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.html", "My &lt;div&gt;partial&lt;/div&gt;.")
+}
+
 // Issue #13593.
 func TestGoatAndNoGoat(t *testing.T) {
 	t.Parallel()
@@ -1103,6 +1123,35 @@ All.
 	b.AssertLogContains("unrecognized render hook")
 }
 
+func TestLayoutNotFound(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- layouts/single.html --
+Single.
+`
+	b := hugolib.Test(t, files, hugolib.TestOptWarn())
+	b.AssertLogContains("WARN  found no layout file for \"html\" for kind \"home\"")
+}
+
+func TestLayoutOverrideThemeWhenThemeOnOldFormatIssue13715(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+theme = "mytheme"
+-- layouts/list.html --
+ layouts/list.html
+-- themes/mytheme/layouts/_default/list.html --
+mytheme/layouts/_default/list.html
+
+`
+
+	b := hugolib.Test(t, files)
+	b.AssertFileContent("public/index.html", "layouts/list.html")
+}
+
 func BenchmarkExecuteWithContext(b *testing.B) {
 	files := `
 -- hugo.toml --
@@ -1197,8 +1246,8 @@ s2.
 			Category: tplimpl.CategoryShortcode,
 			Desc:     desc,
 		}
-		v := store.LookupShortcode(q)
-		if v == nil {
+		v, err := store.LookupShortcode(q)
+		if v == nil || err != nil {
 			b.Fatal("not found")
 		}
 	}