Repository: gohugoio/hugo (language: go)

Issue:
Consider _build vs build
### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.123.0-DEV-4174a7866b75c6ae10827cc77dbae0676af8e5eb+extended darwin/arm64 BuildDate=2024-02-01T18:42:55Z

</pre>

### Does this issue reproduce with the latest release?

Yes

## Description

The site is building with Hugo 0.122.0, using the latest dev version it fails with this rather cryptic message:

```
ERROR Failed to create content converter: no content renderer found for markup ""
```

@bep: Cann you please add a hint, like template file and line to be able to narrow it down?
Glob patterns in v0.123.0
I was sort of expecting this, but using a `path` Glob pattern while cascading a value seems to look for the canonical page path.

I'm not sure this is a bug, but the behavior has changed from v0.122.0.

```text
content/
├── posts/
│   ├── _index.de.md
│   ├── _index.en.md
│   ├── post-1.de.md
│   └── post-1.en.md
├── _index.de.md
└── _index.en.md
````

content/_index.de.md

```
[[cascade]]
[cascade.params]
foo = 'bar'
[cascade._target]
path = '/posts/post-1.de.md'
```

The above works in v0.122.0.

But this is required in v0.123.0:

```
[[cascade]]
[cascade.params]
foo = 'bar'
[cascade._target]
path = '/posts/post-1'
```

We use Glob patterns elsewhere, but I didn't check any of those.

I'm also not sure how the `lang` keyword is supposed to work in this situation. It seems like it should be ignored (it is) when cascading from a node.

Finally, the example above may not be realistic. I think in most cases the Glob pattern is used to target a directory instead of a page, but I could be wrong.



Discussion / hints:
Are you passing "markup" as a key in the options map for .Page.RenderString?  e.g., 

```text
{{ $opts := dict "markup" "pandoc" }}
{{ $s | .RenderString $opts }} 
```

Or do you have a `markup` key in front matter, even an empty one?
No, neither of both. And just passing a empty string as `markup` in the font matter doesn't seem to trigger this.

But using `disableKinds` let me narrow it down to some template that is only called for `term`'s - I'll check that.
>Cann you please add a hint, like template file and line to be able to narrow it down?

I added some page info ... But I fail to understand why the above happens.
@bep: Thanks, that was very helpful.
@jmooring: You were close I've got a front matter field `build` (since it's a site about architecture and uses the field to indicate when a building was topped out). Commenting this out fixes this message. But I can change that...

Thanks both! 
>build (since it's a site about architecture and uses the field to indicate when a building was topped out). Commenting this out fixes this message. But I can change that...

Hmm... I'm not sure I see the connection, but yea, I boldly renamed the `_build` to `build` not thinking that it would have any side-effects...
Well, I'm just dumping metadata on a object described by a `tag` page in the frontmatter. 
Just to avoid similar issues: This site also uses a `type` front matter field for tag pages to distinguish between buildings and architects.

BTW: I really like the error messages with a reference to the causing content file, this also has the benefit that they aren't eaten up.
@cmahnke OK. I will see if I can make that change less breaking. Note that we have created a separation between "Hugo page config" and "user defined params", so the recommended setup in 0.123.0 would be:

```
---
title: My page
params:
   myuserparam: foo
   build: some build setup that's not related to Hugo's build/_build
---
```
@bep Yes, I will do that. I guess the usual deprecation rules will apply for custom front matter fields as well.

Thanks!
The above should work (for backwards compability), I guess (I find it unusual to have a Gob path pattern without any asterisk in it -- why not put that value directly into the file you target?)

Edit in: I will try to make the above cases work, but I will issue a warning about it. The meaning of `Path` has changed, and we have been announcing it for the last 2 years.
>I'm also not sure how the lang keyword is supposed to work in this situation. It seems like it should be ignored (it is) when cascading from a node.


The `lang` keyword only makes sense in the config cascade. A content page is always attached to one language and cascaded is only applied to pages in the same language.


Fix patch:
diff --git a/common/hugio/readers.go b/common/hugio/readers.go
index feb1b141214..9745d784152 100644
--- a/common/hugio/readers.go
+++ b/common/hugio/readers.go
@@ -37,32 +37,47 @@ type ReadSeekCloserProvider interface {
 	ReadSeekCloser() (ReadSeekCloser, error)
 }
 
-// ReadSeekerNoOpCloser implements ReadSeekCloser by doing nothing in Close.
-// TODO(bep) rename this and similar to ReadSeekerNopCloser, naming used in stdlib, which kind of makes sense.
-type ReadSeekerNoOpCloser struct {
+// readSeekerNopCloser implements ReadSeekCloser by doing nothing in Close.
+type readSeekerNopCloser struct {
 	ReadSeeker
 }
 
 // Close does nothing.
-func (r ReadSeekerNoOpCloser) Close() error {
+func (r readSeekerNopCloser) Close() error {
 	return nil
 }
 
 // NewReadSeekerNoOpCloser creates a new ReadSeekerNoOpCloser with the given ReadSeeker.
-func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekerNoOpCloser {
-	return ReadSeekerNoOpCloser{r}
+func NewReadSeekerNoOpCloser(r ReadSeeker) ReadSeekCloser {
+	return readSeekerNopCloser{r}
 }
 
 // NewReadSeekerNoOpCloserFromString uses strings.NewReader to create a new ReadSeekerNoOpCloser
 // from the given string.
-func NewReadSeekerNoOpCloserFromString(content string) ReadSeekerNoOpCloser {
-	return ReadSeekerNoOpCloser{strings.NewReader(content)}
+func NewReadSeekerNoOpCloserFromString(content string) ReadSeekCloser {
+	return strigReadSeeker{s: content, readSeekerNopCloser: readSeekerNopCloser{strings.NewReader(content)}}
+}
+
+var _ StringReader = (*strigReadSeeker)(nil)
+
+type strigReadSeeker struct {
+	s string
+	readSeekerNopCloser
+}
+
+func (s *strigReadSeeker) ReadString() string {
+	return s.s
+}
+
+// StringReader provides a way to read a string.
+type StringReader interface {
+	ReadString() string
 }
 
 // NewReadSeekerNoOpCloserFromString uses strings.NewReader to create a new ReadSeekerNoOpCloser
 // from the given bytes slice.
-func NewReadSeekerNoOpCloserFromBytes(content []byte) ReadSeekerNoOpCloser {
-	return ReadSeekerNoOpCloser{bytes.NewReader(content)}
+func NewReadSeekerNoOpCloserFromBytes(content []byte) readSeekerNopCloser {
+	return readSeekerNopCloser{bytes.NewReader(content)}
 }
 
 // NewReadSeekCloser creates a new ReadSeekCloser from the given ReadSeeker.
@@ -77,3 +92,15 @@ func NewOpenReadSeekCloser(r ReadSeekCloser) OpenReadSeekCloser {
 // OpenReadSeekCloser allows setting some other way (than reading from a filesystem)
 // to open or create a ReadSeekCloser.
 type OpenReadSeekCloser func() (ReadSeekCloser, error)
+
+// ReadString reads from the given reader and returns the content as a string.
+func ReadString(r io.Reader) (string, error) {
+	if sr, ok := r.(StringReader); ok {
+		return sr.ReadString(), nil
+	}
+	b, err := io.ReadAll(r)
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index 413baebd12f..7052f0abde0 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -965,7 +965,7 @@ func decodeConfigFromParams(fs afero.Fs, logger loggers.Logger, bcfg config.Base
 	})
 
 	for _, v := range decoderSetups {
-		p := decodeConfig{p: p, c: target, fs: fs, bcfg: bcfg}
+		p := decodeConfig{p: p, c: target, fs: fs, logger: logger, bcfg: bcfg}
 		if err := v.decode(v, p); err != nil {
 			return fmt.Errorf("failed to decode %q: %w", v.key, err)
 		}
diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go
index f96c19cfc75..5ab76dec59f 100644
--- a/config/allconfig/alldecoders.go
+++ b/config/allconfig/alldecoders.go
@@ -18,6 +18,7 @@ import (
 	"strings"
 
 	"github.com/gohugoio/hugo/cache/filecache"
+	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/config"
@@ -42,10 +43,11 @@ import (
 )
 
 type decodeConfig struct {
-	p    config.Provider
-	c    *Config
-	fs   afero.Fs
-	bcfg config.BaseConfig
+	p      config.Provider
+	c      *Config
+	fs     afero.Fs
+	logger loggers.Logger
+	bcfg   config.BaseConfig
 }
 
 type decodeWeight struct {
@@ -291,7 +293,7 @@ var allDecoderSetups = map[string]decodeWeight{
 		key: "cascade",
 		decode: func(d decodeWeight, p decodeConfig) error {
 			var err error
-			p.c.Cascade, err = page.DecodeCascadeConfig(p.p.Get(d.key))
+			p.c.Cascade, err = page.DecodeCascadeConfig(p.logger, p.p.Get(d.key))
 			return err
 		},
 	},
diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go
index 9accd190ec4..27b37a8fc35 100644
--- a/hugolib/content_map_page.go
+++ b/hugolib/content_map_page.go
@@ -1239,7 +1239,9 @@ func (sa *sitePagesAssembler) applyAggregates() error {
 		}
 
 		// Combine the cascade map with front matter.
-		pageBundle.setMetaPost(cascade)
+		if err := pageBundle.setMetaPost(cascade); err != nil {
+			return false, err
+		}
 
 		// We receive cascade values from above. If this leads to a change compared
 		// to the previous value, we need to mark the page and its dependencies as changed.
@@ -1304,7 +1306,9 @@ func (sa *sitePagesAssembler) applyAggregates() error {
 				if data != nil {
 					cascade = data.(map[page.PageMatcher]maps.Params)
 				}
-				pageResource.setMetaPost(cascade)
+				if err := pageResource.setMetaPost(cascade); err != nil {
+					return false, err
+				}
 			}
 
 			return false, nil
@@ -1369,7 +1373,9 @@ func (sa *sitePagesAssembler) applyAggregatesToTaxonomiesAndTerms() error {
 					if data != nil {
 						cascade = data.(map[page.PageMatcher]maps.Params)
 					}
-					p.setMetaPost(cascade)
+					if err := p.setMetaPost(cascade); err != nil {
+						return false, err
+					}
 
 					if err := sa.pageMap.treeTaxonomyEntries.WalkPrefix(
 						doctree.LockTypeRead,
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index b19a82fde57..7b785abb613 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -33,6 +33,7 @@ import (
 
 	"github.com/gohugoio/hugo/common/constants"
 	"github.com/gohugoio/hugo/common/hugo"
+	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/common/paths"
 	"github.com/gohugoio/hugo/config"
@@ -272,7 +273,7 @@ func (p *pageMeta) Weight() int {
 	return p.pageConfig.Weight
 }
 
-func (p *pageMeta) setMetaPre(pi *contentParseInfo, conf config.AllProvider) error {
+func (p *pageMeta) setMetaPre(pi *contentParseInfo, logger loggers.Logger, conf config.AllProvider) error {
 	frontmatter := pi.frontMatter
 	if frontmatter != nil {
 		pcfg := p.pageConfig
@@ -285,7 +286,7 @@ func (p *pageMeta) setMetaPre(pi *contentParseInfo, conf config.AllProvider) err
 		// Check for any cascade define on itself.
 		if cv, found := frontmatter["cascade"]; found {
 			var err error
-			cascade, err := page.DecodeCascade(cv)
+			cascade, err := page.DecodeCascade(logger, cv)
 			if err != nil {
 				return err
 			}
@@ -428,15 +429,28 @@ func (p *pageState) setMetaPostParams() error {
 	}
 
 	var buildConfig any
+	var isNewBuildKeyword bool
 	if v, ok := pm.pageConfig.Params["_build"]; ok {
 		buildConfig = v
 	} else {
 		buildConfig = pm.pageConfig.Params["build"]
+		isNewBuildKeyword = true
 	}
-
 	pm.pageConfig.Build, err = pagemeta.DecodeBuildConfig(buildConfig)
 	if err != nil {
-		return err
+		var msgDetail string
+		if isNewBuildKeyword {
+			msgDetail = `. We renamed the _build keyword to build in Hugo 0.123.0. We recommend putting user defined params in the params section, e.g.:
+---
+title: "My Title"
+params:
+  build: "My Build"
+---
+´   
+
+`
+		}
+		return fmt.Errorf("failed to decode build config in front matter: %s%s", err, msgDetail)
 	}
 
 	var sitemapSet bool
diff --git a/hugolib/page__new.go b/hugolib/page__new.go
index d846fe03c25..818cbb5db2b 100644
--- a/hugolib/page__new.go
+++ b/hugolib/page__new.go
@@ -56,7 +56,7 @@ func (h *HugoSites) newPage(m *pageMeta) (*pageState, *paths.Path, error) {
 		return nil, nil, err
 	}
 
-	if err := m.setMetaPre(pi, h.Conf); err != nil {
+	if err := m.setMetaPre(pi, h.Log, h.Conf); err != nil {
 		return nil, nil, m.wrapError(err, h.BaseFs.SourceFs)
 	}
 	pcfg := m.pageConfig
diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go
index fbdb25d7247..466b6fe53c8 100644
--- a/resources/page/page_matcher.go
+++ b/resources/page/page_matcher.go
@@ -18,6 +18,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/hugofs/glob"
@@ -90,7 +91,14 @@ var disallowedCascadeKeys = map[string]bool{
 	"lang": true,
 }
 
-func DecodeCascadeConfig(in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, map[PageMatcher]maps.Params], error) {
+// See issue 11977.
+func isGlobWithExtension(s string) bool {
+	pathParts := strings.Split(s, "/")
+	last := pathParts[len(pathParts)-1]
+	return strings.Count(last, ".") > 0
+}
+
+func DecodeCascadeConfig(logger loggers.Logger, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, map[PageMatcher]maps.Params], error) {
 	buildConfig := func(in any) (map[PageMatcher]maps.Params, any, error) {
 		cascade := make(map[PageMatcher]maps.Params)
 		if in == nil {
@@ -119,6 +127,9 @@ func DecodeCascadeConfig(in any) (*config.ConfigNamespace[[]PageMatcherParamsCon
 
 		for _, cfg := range cfgs {
 			m := cfg.Target
+			if isGlobWithExtension(m.Path) {
+				logger.Erroridf("cascade-pattern-with-extension", "cascade target path %q looks like a path with an extension; since Hugo v0.123.0 this will not match anything, see  https://gohugo.io/methods/page/path/", m.Path)
+			}
 			c, found := cascade[m]
 			if found {
 				// Merge
@@ -139,8 +150,8 @@ func DecodeCascadeConfig(in any) (*config.ConfigNamespace[[]PageMatcherParamsCon
 }
 
 // DecodeCascade decodes in which could be either a map or a slice of maps.
-func DecodeCascade(in any) (map[PageMatcher]maps.Params, error) {
-	conf, err := DecodeCascadeConfig(in)
+func DecodeCascade(logger loggers.Logger, in any) (map[PageMatcher]maps.Params, error) {
+	conf, err := DecodeCascadeConfig(logger, in)
 	if err != nil {
 		return nil, err
 	}
diff --git a/resources/resource.go b/resources/resource.go
index 6bef1b2756e..c11756bcaab 100644
--- a/resources/resource.go
+++ b/resources/resource.go
@@ -418,12 +418,7 @@ func (l *genericResource) Content(context.Context) (any, error) {
 	}
 	defer r.Close()
 
-	var b []byte
-	b, err = io.ReadAll(r)
-	if err != nil {
-		return "", err
-	}
-	return string(b), nil
+	return hugio.ReadString(r)
 }
 
 func (r *genericResource) Err() resource.ResourceError {


Test patch:
diff --git a/hugolib/cascade_test.go b/hugolib/cascade_test.go
index 194faeb0a39..bb328f76178 100644
--- a/hugolib/cascade_test.go
+++ b/hugolib/cascade_test.go
@@ -671,3 +671,32 @@ S1|p1:|p2:p2|
 		`)
 	})
 }
+
+// Issue 11977.
+func TestCascadeExtensionInPath(t *testing.T) {
+	files := `
+-- hugo.toml --
+baseURL = "https://example.org"
+[languages]
+[languages.en]
+weight = 1
+[languages.de]
+-- content/_index.de.md --
++++
+[[cascade]]
+[cascade.params]
+foo = 'bar'
+[cascade._target]
+path = '/posts/post-1.de.md'
++++
+-- content/posts/post-1.de.md --
+---
+title: "Post 1"
+---
+-- layouts/_default/single.html --
+{{ .Title }}|{{ .Params.foo }}$
+`
+	b, err := TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.AssertLogContains(`cascade target path "/posts/post-1.de.md" looks like a path with an extension; since Hugo v0.123.0 this will not match anything, see  https://gohugo.io/methods/page/path/`)
+}
diff --git a/hugolib/params_test.go b/hugolib/params_test.go
index 6f890b43b78..cbcc8e540de 100644
--- a/hugolib/params_test.go
+++ b/hugolib/params_test.go
@@ -181,3 +181,24 @@ lang = 'nn'
 	b, err := TestE(t, files)
 	b.Assert(err, qt.IsNotNil)
 }
+
+// Issue 11970.
+func TestFrontMatterBuildIsHugoKeyword(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+baseURL = "https://example.org/"
+-- content/p1.md --
+---
+title: "P1"
+build: "foo"
+---
+-- layouts/_default/single.html --
+Params: {{ range $k, $v := .Params }}{{ $k }}: {{ $v }}|{{ end }}$
+`
+	b, err := TestE(t, files)
+
+	b.Assert(err, qt.IsNotNil)
+	b.Assert(err.Error(), qt.Contains, "We renamed the _build keyword")
+}
diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
index c27a2e9b22a..e659eb3b5d0 100644
--- a/resources/page/page_matcher_test.go
+++ b/resources/page/page_matcher_test.go
@@ -18,6 +18,7 @@ import (
 	"testing"
 
 	"github.com/gohugoio/hugo/common/hugo"
+	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 
 	qt "github.com/frankban/quicktest"
@@ -128,7 +129,7 @@ func TestDecodeCascadeConfig(t *testing.T) {
 		},
 	}
 
-	got, err := DecodeCascadeConfig(in)
+	got, err := DecodeCascadeConfig(loggers.NewDefault(), in)
 
 	c.Assert(err, qt.IsNil)
 	c.Assert(got, qt.IsNotNil)
@@ -150,7 +151,7 @@ func TestDecodeCascadeConfig(t *testing.T) {
 		{Params: maps.Params{"b": string("bv")}, Target: PageMatcher{Kind: "page"}},
 	})
 
-	got, err = DecodeCascadeConfig(nil)
+	got, err = DecodeCascadeConfig(loggers.NewDefault(), nil)
 	c.Assert(err, qt.IsNil)
 	c.Assert(got, qt.IsNotNil)
 }
@@ -172,3 +173,17 @@ func (c testConfig) Running() bool {
 func (c testConfig) WorkingDir() string {
 	return c.workingDir
 }
+
+func TestIsGlobWithExtension(t *testing.T) {
+	c := qt.New(t)
+
+	c.Assert(isGlobWithExtension("index.md"), qt.Equals, true)
+	c.Assert(isGlobWithExtension("foo/index.html"), qt.Equals, true)
+	c.Assert(isGlobWithExtension("posts/page"), qt.Equals, false)
+	c.Assert(isGlobWithExtension("pa.th/foo"), qt.Equals, false)
+	c.Assert(isGlobWithExtension(""), qt.Equals, false)
+	c.Assert(isGlobWithExtension("*.md?"), qt.Equals, true)
+	c.Assert(isGlobWithExtension("*.md*"), qt.Equals, true)
+	c.Assert(isGlobWithExtension("posts/*"), qt.Equals, false)
+	c.Assert(isGlobWithExtension("*.md"), qt.Equals, true)
+}