Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ba5d812

Browse files
bepclaude
andcommitted
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) <[email protected]>
1 parent be4a0df commit ba5d812

2 files changed

Lines changed: 150 additions & 1 deletion

File tree

config/configLoader.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,11 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid
173173
}
174174

175175
var keyPath []string
176+
var unwrapKey string
176177
if !DefaultConfigNamesSet[name] {
177178
// Can be params.jp, menus.en etc.
178179
name, lang := paths.FileAndExtNoDelimiter(name)
180+
unwrapKey = name
179181

180182
keyPath = []string{name}
181183

@@ -190,13 +192,23 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid
190192
}
191193
}
192194

195+
// TOML/YAML can't represent a headless top-level array, so allow a
196+
// file to wrap its content under a single top-level key matching
197+
// the basename (e.g. cascade.yaml with `cascade: [...]`).
198+
var itemValue any = item
199+
if unwrapKey != "" && len(item) == 1 {
200+
if inner, ok := item[unwrapKey]; ok {
201+
itemValue = inner
202+
}
203+
}
204+
193205
root := item
194206
if len(keyPath) > 0 {
195207
root = make(map[string]any)
196208
m := root
197209
for i, key := range keyPath {
198210
if i >= len(keyPath)-1 {
199-
m[key] = item
211+
m[key] = itemValue
200212
} else {
201213
nm := make(map[string]any)
202214
m[key] = nm

hugolib/configdir_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,140 @@ Params: map[a:acp1 b:bc1 c:c1 d:dcp1]
4646
4747
`)
4848
}
49+
50+
// TOML/YAML can't represent a top-level array, so a basename-matched wrapper
51+
// key in the config file unwraps to the slice-typed root key.
52+
func TestConfigDirCascadeSliceIssue12899(t *testing.T) {
53+
t.Parallel()
54+
55+
files := `
56+
-- hugo.toml --
57+
baseURL = "https://example.com"
58+
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"]
59+
-- config/_default/cascade.yaml --
60+
cascade:
61+
- target:
62+
path: /books/**
63+
params:
64+
color: red
65+
- target:
66+
path: /films/**
67+
params:
68+
color: blue
69+
-- content/books/b1.md --
70+
---
71+
title: B1
72+
---
73+
-- content/films/f1.md --
74+
---
75+
title: F1
76+
---
77+
-- layouts/page.html --
78+
{{ .Title }}|color:{{ .Params.color }}|
79+
`
80+
b := Test(t, files)
81+
82+
b.AssertFileContent("public/books/b1/index.html", "B1|color:red|")
83+
b.AssertFileContent("public/films/f1/index.html", "F1|color:blue|")
84+
}
85+
86+
func TestConfigDirPermalinksSliceIssue12899(t *testing.T) {
87+
t.Parallel()
88+
89+
files := `
90+
-- hugo.toml --
91+
baseURL = "https://example.com"
92+
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"]
93+
-- config/_default/permalinks.yaml --
94+
permalinks:
95+
- target:
96+
path: /books/**
97+
pattern: /shelf/:slug/
98+
-- content/books/b1.md --
99+
---
100+
title: B1
101+
slug: novel
102+
---
103+
-- layouts/page.html --
104+
{{ .Title }}|{{ .RelPermalink }}|
105+
`
106+
b := Test(t, files)
107+
108+
b.AssertFileContent("public/shelf/novel/index.html", "B1|/shelf/novel/|")
109+
}
110+
111+
func TestConfigDirCascadeEnvironmentOverrideIssue12899(t *testing.T) {
112+
t.Parallel()
113+
114+
files := `
115+
-- hugo.toml --
116+
baseURL = "https://example.com"
117+
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"]
118+
-- config/_default/cascade.yaml --
119+
cascade:
120+
- target:
121+
path: /**
122+
params:
123+
color: default
124+
-- config/production/cascade.yaml --
125+
cascade:
126+
- target:
127+
path: /**
128+
params:
129+
color: production
130+
-- content/p1.md --
131+
---
132+
title: P1
133+
---
134+
-- layouts/page.html --
135+
{{ .Title }}|color:{{ .Params.color }}|
136+
`
137+
b := Test(t, files)
138+
139+
b.AssertFileContent("public/p1/index.html", "P1|color:production|")
140+
}
141+
142+
// The basename-match unwrap is type-agnostic — also unwraps maps.
143+
func TestConfigDirRepeatRootMapIssue14882(t *testing.T) {
144+
t.Parallel()
145+
146+
files := `
147+
-- hugo.toml --
148+
baseURL = "https://example.com"
149+
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "section"]
150+
-- config/_default/params.yaml --
151+
params:
152+
a: aval
153+
b: bval
154+
-- layouts/home.html --
155+
a:{{ site.Params.a }}|b:{{ site.Params.b }}|
156+
`
157+
b := Test(t, files)
158+
159+
b.AssertFileContent("public/index.html", "a:aval|b:bval|")
160+
}
161+
162+
// The unwrap fires only when the basename-matched key is the sole top-level
163+
// key in the file. A mixed map must be left alone.
164+
func TestConfigDirUnwrapOnlySoleKeyIssue12899(t *testing.T) {
165+
t.Parallel()
166+
167+
files := `
168+
-- hugo.toml --
169+
baseURL = "https://example.com"
170+
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT", "home", "section"]
171+
-- config/_default/params.yaml --
172+
params:
173+
nested: yes
174+
other: top
175+
-- content/p1.md --
176+
---
177+
title: P1
178+
---
179+
-- layouts/page.html --
180+
{{ .Title }}|params.params.nested:{{ site.Params.params.nested }}|params.other:{{ site.Params.other }}|
181+
`
182+
b := Test(t, files)
183+
184+
b.AssertFileContent("public/p1/index.html", "P1|params.params.nested:yes|params.other:top|")
185+
}

0 commit comments

Comments
 (0)