From ba5d812673c1ba5d07650cefa849c98ec6979b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Wed, 13 May 2026 13:14:06 +0200 Subject: [PATCH] config: Allow repeating the root key in /config files If a non-default-name file in the config folder parses to a map with a single top-level key matching the file's basename, unwrap it. This lets TOML/YAML express slice-typed roots (cascade, permalinks), which can't have a headless top-level array, and also lets users copy-paste docs examples that include the root container (e.g. params.yaml with a top-level params: block). Fixes #12899 Fixes #14882 Co-Authored-By: Claude Opus 4.7 (1M context) --- config/configLoader.go | 14 +++- hugolib/configdir_test.go | 137 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/config/configLoader.go b/config/configLoader.go index 49fc27fc6..dde6f2a95 100644 --- a/config/configLoader.go +++ b/config/configLoader.go @@ -173,9 +173,11 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid } var keyPath []string + var unwrapKey string if !DefaultConfigNamesSet[name] { // Can be params.jp, menus.en etc. name, lang := paths.FileAndExtNoDelimiter(name) + unwrapKey = name keyPath = []string{name} @@ -190,13 +192,23 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid } } + // TOML/YAML can't represent a headless top-level array, so allow a + // file to wrap its content under a single top-level key matching + // the basename (e.g. cascade.yaml with `cascade: [...]`). + var itemValue any = item + if unwrapKey != "" && len(item) == 1 { + if inner, ok := item[unwrapKey]; ok { + itemValue = inner + } + } + root := item if len(keyPath) > 0 { root = make(map[string]any) m := root for i, key := range keyPath { if i >= len(keyPath)-1 { - m[key] = item + m[key] = itemValue } else { nm := make(map[string]any) m[key] = nm diff --git a/hugolib/configdir_test.go b/hugolib/configdir_test.go index e47b75cee..4e550f44f 100644 --- a/hugolib/configdir_test.go +++ b/hugolib/configdir_test.go @@ -46,3 +46,140 @@ Params: map[a:acp1 b:bc1 c:c1 d:dcp1] `) } + +// TOML/YAML can't represent a top-level array, so a basename-matched wrapper +// key in the config file unwraps to the slice-typed root key. +func TestConfigDirCascadeSliceIssue12899(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"] +-- config/_default/cascade.yaml -- +cascade: + - target: + path: /books/** + params: + color: red + - target: + path: /films/** + params: + color: blue +-- content/books/b1.md -- +--- +title: B1 +--- +-- content/films/f1.md -- +--- +title: F1 +--- +-- layouts/page.html -- +{{ .Title }}|color:{{ .Params.color }}| +` + b := Test(t, files) + + b.AssertFileContent("public/books/b1/index.html", "B1|color:red|") + b.AssertFileContent("public/films/f1/index.html", "F1|color:blue|") +} + +func TestConfigDirPermalinksSliceIssue12899(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"] +-- config/_default/permalinks.yaml -- +permalinks: + - target: + path: /books/** + pattern: /shelf/:slug/ +-- content/books/b1.md -- +--- +title: B1 +slug: novel +--- +-- layouts/page.html -- +{{ .Title }}|{{ .RelPermalink }}| +` + b := Test(t, files) + + b.AssertFileContent("public/shelf/novel/index.html", "B1|/shelf/novel/|") +} + +func TestConfigDirCascadeEnvironmentOverrideIssue12899(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"] +-- config/_default/cascade.yaml -- +cascade: + - target: + path: /** + params: + color: default +-- config/production/cascade.yaml -- +cascade: + - target: + path: /** + params: + color: production +-- content/p1.md -- +--- +title: P1 +--- +-- layouts/page.html -- +{{ .Title }}|color:{{ .Params.color }}| +` + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", "P1|color:production|") +} + +// The basename-match unwrap is type-agnostic — also unwraps maps. +func TestConfigDirRepeatRootMapIssue14882(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "section"] +-- config/_default/params.yaml -- +params: + a: aval + b: bval +-- layouts/home.html -- +a:{{ site.Params.a }}|b:{{ site.Params.b }}| +` + b := Test(t, files) + + b.AssertFileContent("public/index.html", "a:aval|b:bval|") +} + +// The unwrap fires only when the basename-matched key is the sole top-level +// key in the file. A mixed map must be left alone. +func TestConfigDirUnwrapOnlySoleKeyIssue12899(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com" +disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"] +-- config/_default/params.yaml -- +params: + nested: yes +other: top +-- content/p1.md -- +--- +title: P1 +--- +-- layouts/page.html -- +{{ .Title }}|params.params.nested:{{ site.Params.params.nested }}|params.other:{{ site.Params.other }}| +` + b := Test(t, files) + + b.AssertFileContent("public/p1/index.html", "P1|params.params.nested:yes|params.other:top|") +}