Repository: gohugoio/hugo (go)

Issue:
Failure to cascade page params from site config to pages created by content adapters
I noticed this on the docs site, where news items created from a content adapter no longer display a publication date.

The test below passes with v0.147.2 and earlier.

```go
func TestIssue13743(t *testing.T) {
	t.Parallel()

	files := `
-- hugo.toml --
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
[cascade.params]
foo = 'bar'
[cascade.target]
path = '/p1'
-- content/_content.gotmpl --
{{ .AddPage (dict "title" "p1" "path" "p1") }}
-- layouts/all.html --
{{ .Title }}|{{ .Params.foo }}
`

	b := hugolib.Test(t, files)

	b.AssertFileContent("public/p1/index.html", "p1|bar") // actual content is "p1|"
}
```

The failure began in v0.147.3, presumably related to https://github.com/gohugoio/hugo/pull/13696. 

Pull request description:
Fix recent regression with cascading of params to content adapters
Fixes #13743


Patch:
diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go
index b0a2f9fc4..d10523a84 100644
--- a/common/paths/pathparser.go
+++ b/common/paths/pathparser.go
@@ -120,11 +120,11 @@ func (pp *PathParser) parse(component, s string) (*Path, error) {
 	return p, nil
 }
 
-func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot int) {
+func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot, numDots int) {
 	if p.posContainerHigh != -1 {
 		return
 	}
-	mayHaveLang := p.posIdentifierLanguage == -1 && pp.LanguageIndex != nil
+	mayHaveLang := numDots > 1 && p.posIdentifierLanguage == -1 && pp.LanguageIndex != nil
 	mayHaveLang = mayHaveLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts)
 	mayHaveOutputFormat := component == files.ComponentFolderLayouts
 	mayHaveKind := p.posIdentifierKind == -1 && mayHaveOutputFormat
@@ -167,7 +167,6 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot i
 			if langFound {
 				p.identifiersKnown = append(p.identifiersKnown, id)
 				p.posIdentifierLanguage = len(p.identifiersKnown) - 1
-
 			}
 		}
 
@@ -234,19 +233,21 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
 	p.s = s
 	slashCount := 0
 	lastDot := 0
+	lastSlashIdx := strings.LastIndex(s, "/")
+	numDots := strings.Count(s[lastSlashIdx+1:], ".")
 
 	for i := len(s) - 1; i >= 0; i-- {
 		c := s[i]
 
 		switch c {
 		case '.':
-			pp.parseIdentifier(component, s, p, i, lastDot)
+			pp.parseIdentifier(component, s, p, i, lastDot, numDots)
 			lastDot = i
 		case '/':
 			slashCount++
 			if p.posContainerHigh == -1 {
 				if lastDot > 0 {
-					pp.parseIdentifier(component, s, p, i, lastDot)
+					pp.parseIdentifier(component, s, p, i, lastDot, numDots)
 				}
 				p.posContainerHigh = i + 1
 			} else if p.posContainerLow == -1 {
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index d3ee28490..0db0be1d8 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -146,7 +146,7 @@ type Config struct {
 
 	// The cascade configuration section contains the top level front matter cascade configuration options,
 	// a slice of page matcher and params to apply to those pages.
-	Cascade *config.ConfigNamespace[[]page.PageMatcherParamsConfig, *maps.Ordered[page.PageMatcher, maps.Params]] `mapstructure:"-"`
+	Cascade *config.ConfigNamespace[[]page.PageMatcherParamsConfig, *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]] `mapstructure:"-"`
 
 	// The segments defines segments for the site. Used for partial/segmented builds.
 	Segments *config.ConfigNamespace[map[string]segments.SegmentConfig, segments.Segments] `mapstructure:"-"`
@@ -776,7 +776,7 @@ type Configs struct {
 }
 
 func (c *Configs) Validate(logger loggers.Logger) error {
-	c.Base.Cascade.Config.Range(func(p page.PageMatcher, params maps.Params) bool {
+	c.Base.Cascade.Config.Range(func(p page.PageMatcher, cfg page.PageMatcherParamsConfig) bool {
 		page.CheckCascadePattern(logger, p)
 		return true
 	})
diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go
index 9e67fbb1b..ef2daca74 100644
--- a/hugolib/content_map_page.go
+++ b/hugolib/content_map_page.go
@@ -1412,7 +1412,7 @@ func (sa *sitePagesAssembler) applyAggregates() error {
 		}
 
 		// Handle cascades first to get any default dates set.
-		var cascade *maps.Ordered[page.PageMatcher, maps.Params]
+		var cascade *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]
 		if keyPage == "" {
 			// Home page gets it's cascade from the site config.
 			cascade = sa.conf.Cascade.Config
@@ -1424,7 +1424,7 @@ func (sa *sitePagesAssembler) applyAggregates() error {
 		} else {
 			_, data := pw.WalkContext.Data().LongestPrefix(paths.Dir(keyPage))
 			if data != nil {
-				cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
+				cascade = data.(*maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig])
 			}
 		}
 
@@ -1506,11 +1506,11 @@ func (sa *sitePagesAssembler) applyAggregates() error {
 				pageResource := rs.r.(*pageState)
 				relPath := pageResource.m.pathInfo.BaseRel(pageBundle.m.pathInfo)
 				pageResource.m.resourcePath = relPath
-				var cascade *maps.Ordered[page.PageMatcher, maps.Params]
+				var cascade *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]
 				// Apply cascade (if set) to the page.
 				_, data := pw.WalkContext.Data().LongestPrefix(resourceKey)
 				if data != nil {
-					cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
+					cascade = data.(*maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig])
 				}
 				if err := pageResource.setMetaPost(cascade); err != nil {
 					return false, err
@@ -1574,10 +1574,10 @@ func (sa *sitePagesAssembler) applyAggregatesToTaxonomiesAndTerms() error {
 				const eventName = "dates"
 
 				if p.Kind() == kinds.KindTerm {
-					var cascade *maps.Ordered[page.PageMatcher, maps.Params]
+					var cascade *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]
 					_, data := pw.WalkContext.Data().LongestPrefix(s)
 					if data != nil {
-						cascade = data.(*maps.Ordered[page.PageMatcher, maps.Params])
+						cascade = data.(*maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig])
 					}
 					if err := p.setMetaPost(cascade); err != nil {
 						return false, err
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index e8bce20d1..0d6d22e9a 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -83,8 +83,8 @@ type pageMetaParams struct {
 
 	// These are only set in watch mode.
 	datesOriginal   pagemeta.Dates
-	paramsOriginal  map[string]any                               // contains the original params as defined in the front matter.
-	cascadeOriginal *maps.Ordered[page.PageMatcher, maps.Params] // contains the original cascade as defined in the front matter.
+	paramsOriginal  map[string]any                                                // contains the original params as defined in the front matter.
+	cascadeOriginal *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig] // contains the original cascade as defined in the front matter.
 }
 
 func (m *pageMetaParams) init(preserveOriginal bool) {
@@ -291,7 +291,7 @@ func (p *pageMeta) setMetaPre(pi *contentParseInfo, logger loggers.Logger, conf
 	return nil
 }
 
-func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, maps.Params]) error {
+func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig]) error {
 	ps.m.setMetaPostCount++
 	var cascadeHashPre uint64
 	if ps.m.setMetaPostCount > 1 {
@@ -303,15 +303,20 @@ func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, maps.Pa
 	// Apply cascades first so they can be overridden later.
 	if cascade != nil {
 		if ps.m.pageConfig.CascadeCompiled != nil {
-			cascade.Range(func(k page.PageMatcher, v maps.Params) bool {
+			cascade.Range(func(k page.PageMatcher, v page.PageMatcherParamsConfig) bool {
 				vv, found := ps.m.pageConfig.CascadeCompiled.Get(k)
 				if !found {
 					ps.m.pageConfig.CascadeCompiled.Set(k, v)
 				} else {
 					// Merge
-					for ck, cv := range v {
-						if _, found := vv[ck]; !found {
-							vv[ck] = cv
+					for ck, cv := range v.Params {
+						if _, found := vv.Params[ck]; !found {
+							vv.Params[ck] = cv
+						}
+					}
+					for ck, cv := range v.Fields {
+						if _, found := vv.Fields[ck]; !found {
+							vv.Fields[ck] = cv
 						}
 					}
 				}
@@ -341,11 +346,17 @@ func (ps *pageState) setMetaPost(cascade *maps.Ordered[page.PageMatcher, maps.Pa
 
 	// Cascade is also applied to itself.
 	var err error
-	cascade.Range(func(k page.PageMatcher, v maps.Params) bool {
+	cascade.Range(func(k page.PageMatcher, v page.PageMatcherParamsConfig) bool {
 		if !k.Matches(ps) {
 			return true
 		}
-		for kk, vv := range v {
+		for kk, vv := range v.Params {
+			if _, found := ps.m.pageConfig.Params[kk]; !found {
+				ps.m.pageConfig.Params[kk] = vv
+			}
+		}
+
+		for kk, vv := range v.Fields {
 			if ps.m.pageConfig.IsFromContentAdapter {
 				if _, found := ps.m.pageConfig.ContentAdapterData[kk]; !found {
 					ps.m.pageConfig.ContentAdapterData[kk] = vv
diff --git a/resources/page/page_matcher.go b/resources/page/page_matcher.go
index 27a7c7e9e..306c2d03c 100644
--- a/resources/page/page_matcher.go
+++ b/resources/page/page_matcher.go
@@ -105,9 +105,9 @@ func CheckCascadePattern(logger loggers.Logger, m PageMatcher) {
 	}
 }
 
-func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, maps.Params]], error) {
-	buildConfig := func(in any) (*maps.Ordered[PageMatcher, maps.Params], any, error) {
-		cascade := maps.NewOrdered[PageMatcher, maps.Params]()
+func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any) (*config.ConfigNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, PageMatcherParamsConfig]], error) {
+	buildConfig := func(in any) (*maps.Ordered[PageMatcher, PageMatcherParamsConfig], any, error) {
+		cascade := maps.NewOrdered[PageMatcher, PageMatcherParamsConfig]()
 		if in == nil {
 			return cascade, []map[string]any{}, nil
 		}
@@ -124,11 +124,7 @@ func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any)
 				c   PageMatcherParamsConfig
 				err error
 			)
-			if handleLegacyFormat {
-				c, err = mapToPageMatcherParamsConfigLegacy(m)
-			} else {
-				c, err = mapToPageMatcherParamsConfig(m)
-			}
+			c, err = mapToPageMatcherParamsConfig(m)
 			if err != nil {
 				return nil, nil, err
 			}
@@ -147,23 +143,28 @@ func DecodeCascadeConfig(logger loggers.Logger, handleLegacyFormat bool, in any)
 			if found {
 				// Merge
 				for k, v := range cfg.Params {
-					if _, found := c[k]; !found {
-						c[k] = v
+					if _, found := c.Params[k]; !found {
+						c.Params[k] = v
+					}
+				}
+				for k, v := range cfg.Fields {
+					if _, found := c.Fields[k]; !found {
+						c.Fields[k] = v
 					}
 				}
 			} else {
-				cascade.Set(m, cfg.Params)
+				cascade.Set(m, cfg)
 			}
 		}
 
 		return cascade, cfgs, nil
 	}
 
-	return config.DecodeNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, maps.Params]](in, buildConfig)
+	return config.DecodeNamespace[[]PageMatcherParamsConfig, *maps.Ordered[PageMatcher, PageMatcherParamsConfig]](in, buildConfig)
 }
 
 // DecodeCascade decodes in which could be either a map or a slice of maps.
-func DecodeCascade(logger loggers.Logger, handleLegacyFormat bool, in any) (*maps.Ordered[PageMatcher, maps.Params], error) {
+func DecodeCascade(logger loggers.Logger, handleLegacyFormat bool, in any) (*maps.Ordered[PageMatcher, PageMatcherParamsConfig], error) {
 	conf, err := DecodeCascadeConfig(logger, handleLegacyFormat, in)
 	if err != nil {
 		return nil, err
@@ -181,47 +182,22 @@ func mapToPageMatcherParamsConfig(m map[string]any) (PageMatcherParamsConfig, er
 				return pcfg, err
 			}
 			pcfg.Target = target
-		default:
+		case "params":
 			if pcfg.Params == nil {
 				pcfg.Params = make(maps.Params)
 			}
-			pcfg.Params[k] = v
-		}
-	}
-	return pcfg, pcfg.init()
-}
-
-func mapToPageMatcherParamsConfigLegacy(m map[string]any) (PageMatcherParamsConfig, error) {
-	var pcfg PageMatcherParamsConfig
-	for k, v := range m {
-		switch strings.ToLower(k) {
-		case "params":
-			// We simplified the structure of the cascade config in Hugo 0.111.0.
-			// There is a small chance that someone has used the old structure with the params keyword,
-			// those values will now be moved to the top level.
-			// This should be very unlikely as it would lead to constructs like .Params.params.foo,
-			// and most people see params as an Hugo internal keyword.
 			params := maps.ToStringMap(v)
-			if pcfg.Params == nil {
-				pcfg.Params = params
-			} else {
-				for k, v := range params {
-					if _, found := pcfg.Params[k]; !found {
-						pcfg.Params[k] = v
-					}
+			for k, v := range params {
+				if _, found := pcfg.Params[k]; !found {
+					pcfg.Params[k] = v
 				}
 			}
-		case "_target", "target":
-			var target PageMatcher
-			if err := decodePageMatcher(v, &target); err != nil {
-				return pcfg, err
-			}
-			pcfg.Target = target
 		default:
-			if pcfg.Params == nil {
-				pcfg.Params = make(maps.Params)
+			if pcfg.Fields == nil {
+				pcfg.Fields = make(maps.Params)
 			}
-			pcfg.Params[k] = v
+
+			pcfg.Fields[k] = v
 		}
 	}
 	return pcfg, pcfg.init()
@@ -250,10 +226,14 @@ func decodePageMatcher(m any, v *PageMatcher) error {
 type PageMatcherParamsConfig struct {
 	// Apply Params to all Pages matching Target.
 	Params maps.Params
+	// Fields holds all fields but Params.
+	Fields maps.Params
+	// Target is the PageMatcher that this config applies to.
 	Target PageMatcher
 }
 
 func (p *PageMatcherParamsConfig) init() error {
 	maps.PrepareParams(p.Params)
+	maps.PrepareParams(p.Fields)
 	return nil
 }
diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go
index 7dea7ca6b..fd4f7759b 100644
--- a/resources/page/pagemeta/page_frontmatter.go
+++ b/resources/page/pagemeta/page_frontmatter.go
@@ -125,10 +125,10 @@ type PageConfig struct {
 	ContentAdapterData map[string]any `mapstructure:"-" json:"-"`
 
 	// Compiled values.
-	CascadeCompiled         *maps.Ordered[page.PageMatcher, maps.Params] `mapstructure:"-" json:"-"`
-	ContentMediaType        media.Type                                   `mapstructure:"-" json:"-"`
-	ConfiguredOutputFormats output.Formats                               `mapstructure:"-" json:"-"`
-	IsFromContentAdapter    bool                                         `mapstructure:"-" json:"-"`
+	CascadeCompiled         *maps.Ordered[page.PageMatcher, page.PageMatcherParamsConfig] `mapstructure:"-" json:"-"`
+	ContentMediaType        media.Type                                                    `mapstructure:"-" json:"-"`
+	ConfiguredOutputFormats output.Formats                                                `mapstructure:"-" json:"-"`
+	IsFromContentAdapter    bool                                                          `mapstructure:"-" json:"-"`
 }
 
 func ClonePageConfigForRebuild(p *PageConfig, params map[string]any) *PageConfig {


Test patch:
diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go
index a6194e756..ad76b9367 100644
--- a/common/paths/pathparser_test.go
+++ b/common/paths/pathparser_test.go
@@ -563,11 +563,20 @@ func TestParseLayouts(t *testing.T) {
 				c.Assert(p.Type(), qt.Equals, TypePartial)
 			},
 		},
+		{
+			"Shortcode lang in root",
+			"/_shortcodes/no.html",
+			func(c *qt.C, p *Path) {
+				c.Assert(p.Type(), qt.Equals, TypeShortcode)
+				c.Assert(p.Lang(), qt.Equals, "")
+				c.Assert(p.NameNoIdentifier(), qt.Equals, "no")
+			},
+		},
 	}
 
 	for _, test := range tests {
 		c.Run(test.name, func(c *qt.C) {
-			if test.name != "Baseof" {
+			if test.name != "Shortcode lang in root" {
 				// return
 			}
 			test.assert(c, testParser.Parse(files.ComponentFolderLayouts, test.path))
diff --git a/hugolib/cascade_test.go b/hugolib/cascade_test.go
index d0a6730db..10b47764f 100644
--- a/hugolib/cascade_test.go
+++ b/hugolib/cascade_test.go
@@ -917,3 +917,24 @@ title: p2
 	b.AssertFileExists("public/sx/index.html", true)    // failing
 	b.AssertFileExists("public/sx/p2/index.html", true) // failing
 }
+
+func TestCascadeGotmplIssue13743(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ['home','rss','section','sitemap','taxonomy','term']
+[cascade.params]
+foo = 'bar'
+[cascade.target]
+path = '/p1'
+-- content/_content.gotmpl --
+{{ .AddPage (dict "title" "p1" "path" "p1") }}
+-- layouts/all.html --
+{{ .Title }}|{{ .Params.foo }}
+`
+
+	b := Test(t, files)
+
+	b.AssertFileContent("public/p1/index.html", "p1|bar") // actual content is "p1|"
+}
diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
index 2fe6ccc89..ad35da43c 100644
--- a/resources/page/page_matcher_test.go
+++ b/resources/page/page_matcher_test.go
@@ -84,19 +84,17 @@ func TestPageMatcher(t *testing.T) {
 
 	c.Run("mapToPageMatcherParamsConfig", func(c *qt.C) {
 		fn := func(m map[string]any) PageMatcherParamsConfig {
-			v, err := mapToPageMatcherParamsConfigLegacy(m)
+			v, err := mapToPageMatcherParamsConfig(m)
 			c.Assert(err, qt.IsNil)
 			return v
 		}
-		// Legacy.
 		c.Assert(fn(map[string]any{"_target": map[string]any{"kind": "page"}, "foo": "bar"}), qt.DeepEquals, PageMatcherParamsConfig{
-			Params: maps.Params{
+			Fields: maps.Params{
 				"foo": "bar",
 			},
 			Target: PageMatcher{Path: "", Kind: "page", Lang: "", Environment: ""},
 		})
 
-		// Current format.
 		c.Assert(fn(map[string]any{"target": map[string]any{"kind": "page"}, "params": map[string]any{"foo": "bar"}}), qt.DeepEquals, PageMatcherParamsConfig{
 			Params: maps.Params{
 				"foo": "bar",
@@ -134,7 +132,7 @@ func TestDecodeCascadeConfig(t *testing.T) {
 	c.Assert(err, qt.IsNil)
 	c.Assert(got, qt.IsNotNil)
 	c.Assert(got.Config.Keys(), qt.DeepEquals, []PageMatcher{{Kind: "page", Environment: "production"}, {Kind: "page"}})
-	c.Assert(got.Config.Values(), qt.DeepEquals, []maps.Params{{"a": string("av")}, {"b": string("bv")}})
+
 	c.Assert(got.SourceStructure, qt.DeepEquals, []PageMatcherParamsConfig{
 		{
 			Params: maps.Params{"a": string("av")},
diff --git a/tpl/tplimpl/shortcodes_integration_test.go b/tpl/tplimpl/shortcodes_integration_test.go
index e884c9a8d..275e8cc15 100644
--- a/tpl/tplimpl/shortcodes_integration_test.go
+++ b/tpl/tplimpl/shortcodes_integration_test.go
@@ -730,16 +730,16 @@ SHORTCODE
 	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) {
+func TestShortcodeOnlyLanguageInBaseIssue13699And13740(t *testing.T) {
 	t.Parallel()
 
 	files := `
 -- hugo.toml --
 baseURL = 'https://example.org/'
+disableLanguages = ['de']
 [languages]
 [languages.en]
 weight = 1
-disableLanguages = ['de']
 [languages.de]
 weight = 2
 -- layouts/_shortcodes/de.html --