Skip to content

Commit 12ace3a

Browse files
resources/page: Add :sectionslug and :sectionslugs permalink tokens
Add slugified section permalink tokens with fallback behavior and slice syntax support. Fixes #13788
1 parent c14fddd commit 12ace3a

File tree

5 files changed

+198
-5
lines changed

5 files changed

+198
-5
lines changed

‎docs/content/en/_common/permalink-tokens.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@ _comment: Do not remove front matter.
2626
`:section`
2727
: The content's section.
2828

29+
`:sectionslug`
30+
: The content's section using slugified section name. The slugified section name is the `slug` as defined in front matter, else the `title` as defined in front matter, else the automatic title.
31+
2932
`:sections`
3033
: The content's sections hierarchy. You can use a selection of the sections using _slice syntax_: `:sections[1:]` includes all but the first, `:sections[:last]` includes all but the last, `:sections[last]` includes only the last, `:sections[1:2]` includes section 2 and 3. Note that this slice access will not throw any out-of-bounds errors, so you don't have to be exact.
3134

35+
`:sectionslugs`
36+
: The content's sections hierarchy using slugified section names. The slugified section name is the `slug` as defined in front matter, else the `title` as defined in front matter, else the automatic title. You can use a selection of the sections using _slice syntax_: `:sectionslugs[1:]` includes all but the first, `:sectionslugs[:last]` includes all but the last, `:sectionslugs[last]` includes only the last, `:sectionslugs[1:2]` includes section 2 and 3. Note that this slice access will not throw any out-of-bounds errors, so you don't have to be exact.
37+
3238
`:title`
3339
: The `title` as defined in front matter, else the automatic title. Hugo generates titles automatically for section, taxonomy, and term pages that are not backed by a file.
3440

‎resources/page/permalinks.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
6262
}, true
6363
}
6464

65+
if strings.HasPrefix(attr, "sectionslugs[") {
66+
fn := p.toSliceFunc(strings.TrimPrefix(attr, "sectionslugs"))
67+
sectionSlugsFunc := p.withSectionPagesFunc(p.pageToPermalinkSlugElseTitle, func(s ...string) string {
68+
return path.Join(fn(s)...)
69+
})
70+
return sectionSlugsFunc, true
71+
}
72+
6573
// Make sure this comes after all the other checks.
6674
if referenceTime.Format(attr) != attr {
6775
return p.pageToPermalinkDate, true
@@ -87,7 +95,9 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
8795
"weekdayname": p.pageToPermalinkDate,
8896
"yearday": p.pageToPermalinkDate,
8997
"section": p.pageToPermalinkSection,
98+
"sectionslug": p.pageToPermalinkSectionSlug,
9099
"sections": p.pageToPermalinkSections,
100+
"sectionslugs": p.pageToPermalinkSectionSlugs,
91101
"title": p.pageToPermalinkTitle,
92102
"slug": p.pageToPermalinkSlugElseTitle,
93103
"slugorfilename": p.pageToPermalinkSlugElseFilename,
@@ -305,10 +315,25 @@ func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, err
305315
return p.Section(), nil
306316
}
307317

318+
// pageToPermalinkSectionSlug returns the URL-safe form of the first section's slug or title
319+
func (l PermalinkExpander) pageToPermalinkSectionSlug(p Page, attr string) (string, error) {
320+
sectionPage := p.FirstSection()
321+
if sectionPage == nil || sectionPage.IsHome() {
322+
return "", nil
323+
}
324+
return l.pageToPermalinkSlugElseTitle(sectionPage, attr)
325+
}
326+
308327
func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
309328
return p.CurrentSection().SectionsPath(), nil
310329
}
311330

331+
// pageToPermalinkSectionSlugs returns a path built from all ancestor sections using their slugs or titles
332+
func (l PermalinkExpander) pageToPermalinkSectionSlugs(p Page, attr string) (string, error) {
333+
sectionSlugsFunc := l.withSectionPagesFunc(l.pageToPermalinkSlugElseTitle, path.Join)
334+
return sectionSlugsFunc(p, attr)
335+
}
336+
312337
// pageToPermalinkContentBaseName returns the URL-safe form of the content base name.
313338
func (l PermalinkExpander) pageToPermalinkContentBaseName(p Page, _ string) (string, error) {
314339
return l.urlize(p.PathInfo().Unnormalized().BaseNameNoIdentifier()), nil
@@ -333,6 +358,34 @@ func (l PermalinkExpander) translationBaseName(p Page) string {
333358
return p.File().TranslationBaseName()
334359
}
335360

361+
// withSectionPagesFunc returns a function that builds permalink attributes from section pages.
362+
// It applies the transformation function f to each ancestor section (Page), then joins the results with the join function.
363+
//
364+
// Current use is create section-based hierarchical paths using section slugs.
365+
func (l PermalinkExpander) withSectionPagesFunc(f func(Page, string) (string, error), join func(...string) string) func(p Page, s string) (string, error) {
366+
return func(p Page, s string) (string, error) {
367+
var entries []string
368+
currentSection := p.CurrentSection()
369+
370+
// Build section hierarchy: ancestors (reversed to root-first) + current section
371+
sections := currentSection.Ancestors().Reverse()
372+
sections = append(sections, currentSection)
373+
374+
for _, section := range sections {
375+
if section.IsHome() {
376+
continue
377+
}
378+
entry, err := f(section, s)
379+
if err != nil {
380+
return "", err
381+
}
382+
entries = append(entries, entry)
383+
}
384+
385+
return join(entries...), nil
386+
}
387+
}
388+
336389
var (
337390
nilSliceFunc = func(s []string) []string {
338391
return nil

‎resources/page/permalinks_integration_test.go‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ tag = "tags"
3737
[permalinks.page]
3838
withpageslug = '/pageslug/:slug/'
3939
withallbutlastsection = '/:sections[:last]/:slug/'
40+
withallbutlastsectionslug = '/:sectionslugs[:last]/:slug/'
41+
withsectionslug = '/sectionslug/:sectionslug/:slug/'
42+
withsectionslugs = '/sectionslugs/:sectionslugs/:slug/'
4043
[permalinks.section]
4144
withfilefilename = '/sectionwithfilefilename/:filename/'
4245
withfilefiletitle = '/sectionwithfilefiletitle/:title/'
@@ -64,6 +67,39 @@ slug: "withfileslugvalue"
6467
-- content/nofiletitle1/p1.md --
6568
-- content/nofiletitle2/asdf/p1.md --
6669
-- content/withallbutlastsection/subsection/p1.md --
70+
-- content/withallbutlastsectionslug/_index.md --
71+
---
72+
slug: "root-section-slug"
73+
---
74+
-- content/withallbutlastsectionslug/subsection/_index.md --
75+
---
76+
slug: "sub-section-slug"
77+
---
78+
-- content/withallbutlastsectionslug/subsection/p1.md --
79+
---
80+
slug: "page-slug"
81+
---
82+
-- content/withsectionslug/_index.md --
83+
---
84+
slug: "section-root-slug"
85+
---
86+
-- content/withsectionslug/subsection/_index.md --
87+
-- content/withsectionslug/subsection/p1.md --
88+
---
89+
slug: "page1-slug"
90+
---
91+
-- content/withsectionslugs/_index.md --
92+
---
93+
slug: "sections-root-slug"
94+
---
95+
-- content/withsectionslugs/level1/_index.md --
96+
---
97+
slug: "level1-slug"
98+
---
99+
-- content/withsectionslugs/level1/p1.md --
100+
---
101+
slug: "page1-slug"
102+
---
67103
-- content/tags/_index.md --
68104
---
69105
slug: "tagsslug"
@@ -87,6 +123,8 @@ slug: "mytagslug"
87123
// No .File.TranslationBaseName on zero object etc. warnings.
88124
b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
89125
b.AssertFileContent("public/pageslug/p1slugvalue/index.html", "Single|page|/pageslug/p1slugvalue/|")
126+
b.AssertFileContent("public/sectionslug/section-root-slug/page1-slug/index.html", "Single|page|/sectionslug/section-root-slug/page1-slug/|")
127+
b.AssertFileContent("public/sectionslugs/sections-root-slug/level1-slug/page1-slug/index.html", "Single|page|/sectionslugs/sections-root-slug/level1-slug/page1-slug/|")
90128
b.AssertFileContent("public/sectionwithfilefilename/index.html", "List|section|/sectionwithfilefilename/|")
91129
b.AssertFileContent("public/sectionwithfileslug/withfileslugvalue/index.html", "List|section|/sectionwithfileslug/withfileslugvalue/|")
92130
b.AssertFileContent("public/sectionnofilefilename/index.html", "List|section|/sectionnofilefilename/|")
@@ -99,7 +137,7 @@ slug: "mytagslug"
99137

100138
permalinksConf := b.H.Configs.Base.Permalinks
101139
b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{
102-
"page": {"withallbutlastsection": "/:sections[:last]/:slug/", "withpageslug": "/pageslug/:slug/"},
140+
"page": {"withallbutlastsection": "/:sections[:last]/:slug/", "withallbutlastsectionslug": "/:sectionslugs[:last]/:slug/", "withpageslug": "/pageslug/:slug/", "withsectionslug": "/sectionslug/:sectionslug/:slug/", "withsectionslugs": "/sectionslugs/:sectionslugs/:slug/"},
103141
"section": {"nofilefilename": "/sectionnofilefilename/:filename/", "nofileslug": "/sectionnofileslug/:slug/", "nofiletitle1": "/sectionnofiletitle1/:title/", "nofiletitle2": "/sectionnofiletitle2/:sections[:last]/", "withfilefilename": "/sectionwithfilefilename/:filename/", "withfilefiletitle": "/sectionwithfilefiletitle/:title/", "withfileslug": "/sectionwithfileslug/:slug/"},
104142
"taxonomy": {"tags": "/tagsslug/:slug/"},
105143
"term": {"tags": "/tagsslug/tag/:slug/"},
@@ -196,6 +234,55 @@ List.
196234
b.AssertFileContent("public/libros/fiction/2023/book1/index.html", "Single.")
197235
}
198236

237+
func TestPermalinksNestedSectionsWithSlugs(t *testing.T) {
238+
t.Parallel()
239+
240+
files := `
241+
-- hugo.toml --
242+
[permalinks.page]
243+
books = '/libros/:sectionslugs[1:]/:slug'
244+
245+
[permalinks.section]
246+
books = '/libros/:sectionslugs[1:]'
247+
-- content/books/_index.md --
248+
---
249+
title: Books
250+
---
251+
-- content/books/fiction/_index.md --
252+
---
253+
title: Fiction
254+
slug: fictionslug
255+
---
256+
-- content/books/fiction/2023/_index.md --
257+
---
258+
title: 2023
259+
---
260+
-- content/books/fiction/2023/book1/index.md --
261+
---
262+
title: Book One
263+
---
264+
-- layouts/_default/single.html --
265+
Single.
266+
-- layouts/_default/list.html --
267+
List.
268+
`
269+
270+
b := hugolib.NewIntegrationTestBuilder(
271+
hugolib.IntegrationTestConfig{
272+
T: t,
273+
TxtarString: files,
274+
LogLevel: logg.LevelWarn,
275+
}).Build()
276+
277+
t.Log(b.LogString())
278+
// No .File.TranslationBaseName on zero object etc. warnings.
279+
b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
280+
281+
b.AssertFileContent("public/libros/index.html", "List.")
282+
b.AssertFileContent("public/libros/fictionslug/index.html", "List.")
283+
b.AssertFileContent("public/libros/fictionslug/2023/book-one/index.html", "Single.")
284+
}
285+
199286
func TestPermalinksUrlCascade(t *testing.T) {
200287
t.Parallel()
201288

‎resources/page/permalinks_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,47 @@ var testdataPermalinks = []struct {
6262
p.title = "mytitle"
6363
p.file = source.NewContentFileInfoFrom("/", "_index.md")
6464
}, "/test-page/"},
65+
// slug, title. // Section slug
66+
{"/:sectionslug/", true, func(p *testPage) {
67+
p.currentSection = &testPage{slug: "my-slug"}
68+
}, "/my-slug/"},
69+
// slug, title. // Section slugs
70+
{"/:sectionslugs/", true, func(p *testPage) {
71+
// Set up current section with ancestors
72+
currentSection := &testPage{
73+
slug: "c-slug",
74+
kind: "section",
75+
ancestors: Pages{
76+
&testPage{slug: "b-slug", kind: "section"},
77+
&testPage{slug: "a-slug", kind: "section"},
78+
},
79+
}
80+
p.currentSection = currentSection
81+
}, "/a-slug/b-slug/c-slug/"},
82+
// slice: slug, title.
83+
{"/:sectionslugs[0]/:sectionslugs[last]/", true, func(p *testPage) {
84+
currentSection := &testPage{
85+
slug: "c-slug",
86+
kind: "section",
87+
ancestors: Pages{
88+
&testPage{slug: "b-slug", kind: "section"},
89+
&testPage{slug: "a-slug", kind: "section"},
90+
},
91+
}
92+
p.currentSection = currentSection
93+
}, "/a-slug/c-slug/"},
94+
// slice: slug, title.
95+
{"/:sectionslugs[last]/", true, func(p *testPage) {
96+
currentSection := &testPage{
97+
slug: "c-slug",
98+
kind: "section",
99+
ancestors: Pages{
100+
&testPage{slug: "b-slug", kind: "section"},
101+
&testPage{slug: "a-slug", kind: "section"},
102+
},
103+
}
104+
p.currentSection = currentSection
105+
}, "/c-slug/"},
65106
// Failures
66107
{"/blog/:fred", false, nil, ""},
67108
{"/:year//:title", false, nil, ""},

‎resources/page/testhelpers_test.go‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ type testPage struct {
111111

112112
currentSection *testPage
113113
sectionEntries []string
114+
ancestors Pages
114115
}
115116

116117
func (p *testPage) Aliases() []string {
@@ -202,7 +203,12 @@ func (p *testPage) Filename() string {
202203
}
203204

204205
func (p *testPage) FirstSection() Page {
205-
panic("testpage: not implemented")
206+
// Return the current section for regular pages
207+
// For section pages, this would be the section itself
208+
if p.currentSection != nil {
209+
return p.currentSection
210+
}
211+
return p // If no current section, assume this page is the section
206212
}
207213

208214
func (p *testPage) FuzzyWordCount(context.Context) int {
@@ -262,7 +268,7 @@ func (p *testPage) IsDraft() bool {
262268
}
263269

264270
func (p *testPage) IsHome() bool {
265-
panic("testpage: not implemented")
271+
return p.kind == "home"
266272
}
267273

268274
func (p *testPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool {
@@ -278,15 +284,15 @@ func (p *testPage) IsPage() bool {
278284
}
279285

280286
func (p *testPage) IsSection() bool {
281-
panic("testpage: not implemented")
287+
return p.kind == "section"
282288
}
283289

284290
func (p *testPage) IsTranslated() bool {
285291
panic("testpage: not implemented")
286292
}
287293

288294
func (p *testPage) Ancestors() Pages {
289-
panic("testpage: not implemented")
295+
return p.ancestors
290296
}
291297

292298
func (p *testPage) Keywords() []string {

0 commit comments

Comments
 (0)