resources/page: Add :sectionslug and :sectionslugs permalink tokens

Add slugified section permalink tokens with fallback behavior and slice syntax support.

Fixes #13788
This commit is contained in:
Dustin Fischer
2025-08-23 12:35:21 +02:00
committed by GitHub
parent c14fdddada
commit 12ace3ad5c
5 changed files with 198 additions and 5 deletions

View File

@@ -26,9 +26,15 @@ _comment: Do not remove front matter.
`:section`
: The content's section.
`:sectionslug`
: 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.
`:sections`
: 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.
`:sectionslugs`
: 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.
`:title`
: 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.

View File

@@ -62,6 +62,14 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
}, true
}
if strings.HasPrefix(attr, "sectionslugs[") {
fn := p.toSliceFunc(strings.TrimPrefix(attr, "sectionslugs"))
sectionSlugsFunc := p.withSectionPagesFunc(p.pageToPermalinkSlugElseTitle, func(s ...string) string {
return path.Join(fn(s)...)
})
return sectionSlugsFunc, true
}
// Make sure this comes after all the other checks.
if referenceTime.Format(attr) != attr {
return p.pageToPermalinkDate, true
@@ -87,7 +95,9 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
"weekdayname": p.pageToPermalinkDate,
"yearday": p.pageToPermalinkDate,
"section": p.pageToPermalinkSection,
"sectionslug": p.pageToPermalinkSectionSlug,
"sections": p.pageToPermalinkSections,
"sectionslugs": p.pageToPermalinkSectionSlugs,
"title": p.pageToPermalinkTitle,
"slug": p.pageToPermalinkSlugElseTitle,
"slugorfilename": p.pageToPermalinkSlugElseFilename,
@@ -305,10 +315,25 @@ func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, err
return p.Section(), nil
}
// pageToPermalinkSectionSlug returns the URL-safe form of the first section's slug or title
func (l PermalinkExpander) pageToPermalinkSectionSlug(p Page, attr string) (string, error) {
sectionPage := p.FirstSection()
if sectionPage == nil || sectionPage.IsHome() {
return "", nil
}
return l.pageToPermalinkSlugElseTitle(sectionPage, attr)
}
func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
return p.CurrentSection().SectionsPath(), nil
}
// pageToPermalinkSectionSlugs returns a path built from all ancestor sections using their slugs or titles
func (l PermalinkExpander) pageToPermalinkSectionSlugs(p Page, attr string) (string, error) {
sectionSlugsFunc := l.withSectionPagesFunc(l.pageToPermalinkSlugElseTitle, path.Join)
return sectionSlugsFunc(p, attr)
}
// pageToPermalinkContentBaseName returns the URL-safe form of the content base name.
func (l PermalinkExpander) pageToPermalinkContentBaseName(p Page, _ string) (string, error) {
return l.urlize(p.PathInfo().Unnormalized().BaseNameNoIdentifier()), nil
@@ -333,6 +358,34 @@ func (l PermalinkExpander) translationBaseName(p Page) string {
return p.File().TranslationBaseName()
}
// withSectionPagesFunc returns a function that builds permalink attributes from section pages.
// It applies the transformation function f to each ancestor section (Page), then joins the results with the join function.
//
// Current use is create section-based hierarchical paths using section slugs.
func (l PermalinkExpander) withSectionPagesFunc(f func(Page, string) (string, error), join func(...string) string) func(p Page, s string) (string, error) {
return func(p Page, s string) (string, error) {
var entries []string
currentSection := p.CurrentSection()
// Build section hierarchy: ancestors (reversed to root-first) + current section
sections := currentSection.Ancestors().Reverse()
sections = append(sections, currentSection)
for _, section := range sections {
if section.IsHome() {
continue
}
entry, err := f(section, s)
if err != nil {
return "", err
}
entries = append(entries, entry)
}
return join(entries...), nil
}
}
var (
nilSliceFunc = func(s []string) []string {
return nil

View File

@@ -37,6 +37,9 @@ tag = "tags"
[permalinks.page]
withpageslug = '/pageslug/:slug/'
withallbutlastsection = '/:sections[:last]/:slug/'
withallbutlastsectionslug = '/:sectionslugs[:last]/:slug/'
withsectionslug = '/sectionslug/:sectionslug/:slug/'
withsectionslugs = '/sectionslugs/:sectionslugs/:slug/'
[permalinks.section]
withfilefilename = '/sectionwithfilefilename/:filename/'
withfilefiletitle = '/sectionwithfilefiletitle/:title/'
@@ -64,6 +67,39 @@ slug: "withfileslugvalue"
-- content/nofiletitle1/p1.md --
-- content/nofiletitle2/asdf/p1.md --
-- content/withallbutlastsection/subsection/p1.md --
-- content/withallbutlastsectionslug/_index.md --
---
slug: "root-section-slug"
---
-- content/withallbutlastsectionslug/subsection/_index.md --
---
slug: "sub-section-slug"
---
-- content/withallbutlastsectionslug/subsection/p1.md --
---
slug: "page-slug"
---
-- content/withsectionslug/_index.md --
---
slug: "section-root-slug"
---
-- content/withsectionslug/subsection/_index.md --
-- content/withsectionslug/subsection/p1.md --
---
slug: "page1-slug"
---
-- content/withsectionslugs/_index.md --
---
slug: "sections-root-slug"
---
-- content/withsectionslugs/level1/_index.md --
---
slug: "level1-slug"
---
-- content/withsectionslugs/level1/p1.md --
---
slug: "page1-slug"
---
-- content/tags/_index.md --
---
slug: "tagsslug"
@@ -87,6 +123,8 @@ slug: "mytagslug"
// No .File.TranslationBaseName on zero object etc. warnings.
b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
b.AssertFileContent("public/pageslug/p1slugvalue/index.html", "Single|page|/pageslug/p1slugvalue/|")
b.AssertFileContent("public/sectionslug/section-root-slug/page1-slug/index.html", "Single|page|/sectionslug/section-root-slug/page1-slug/|")
b.AssertFileContent("public/sectionslugs/sections-root-slug/level1-slug/page1-slug/index.html", "Single|page|/sectionslugs/sections-root-slug/level1-slug/page1-slug/|")
b.AssertFileContent("public/sectionwithfilefilename/index.html", "List|section|/sectionwithfilefilename/|")
b.AssertFileContent("public/sectionwithfileslug/withfileslugvalue/index.html", "List|section|/sectionwithfileslug/withfileslugvalue/|")
b.AssertFileContent("public/sectionnofilefilename/index.html", "List|section|/sectionnofilefilename/|")
@@ -99,7 +137,7 @@ slug: "mytagslug"
permalinksConf := b.H.Configs.Base.Permalinks
b.Assert(permalinksConf, qt.DeepEquals, map[string]map[string]string{
"page": {"withallbutlastsection": "/:sections[:last]/:slug/", "withpageslug": "/pageslug/:slug/"},
"page": {"withallbutlastsection": "/:sections[:last]/:slug/", "withallbutlastsectionslug": "/:sectionslugs[:last]/:slug/", "withpageslug": "/pageslug/:slug/", "withsectionslug": "/sectionslug/:sectionslug/:slug/", "withsectionslugs": "/sectionslugs/:sectionslugs/:slug/"},
"section": {"nofilefilename": "/sectionnofilefilename/:filename/", "nofileslug": "/sectionnofileslug/:slug/", "nofiletitle1": "/sectionnofiletitle1/:title/", "nofiletitle2": "/sectionnofiletitle2/:sections[:last]/", "withfilefilename": "/sectionwithfilefilename/:filename/", "withfilefiletitle": "/sectionwithfilefiletitle/:title/", "withfileslug": "/sectionwithfileslug/:slug/"},
"taxonomy": {"tags": "/tagsslug/:slug/"},
"term": {"tags": "/tagsslug/tag/:slug/"},
@@ -196,6 +234,55 @@ List.
b.AssertFileContent("public/libros/fiction/2023/book1/index.html", "Single.")
}
func TestPermalinksNestedSectionsWithSlugs(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
[permalinks.page]
books = '/libros/:sectionslugs[1:]/:slug'
[permalinks.section]
books = '/libros/:sectionslugs[1:]'
-- content/books/_index.md --
---
title: Books
---
-- content/books/fiction/_index.md --
---
title: Fiction
slug: fictionslug
---
-- content/books/fiction/2023/_index.md --
---
title: 2023
---
-- content/books/fiction/2023/book1/index.md --
---
title: Book One
---
-- layouts/_default/single.html --
Single.
-- layouts/_default/list.html --
List.
`
b := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: t,
TxtarString: files,
LogLevel: logg.LevelWarn,
}).Build()
t.Log(b.LogString())
// No .File.TranslationBaseName on zero object etc. warnings.
b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
b.AssertFileContent("public/libros/index.html", "List.")
b.AssertFileContent("public/libros/fictionslug/index.html", "List.")
b.AssertFileContent("public/libros/fictionslug/2023/book-one/index.html", "Single.")
}
func TestPermalinksUrlCascade(t *testing.T) {
t.Parallel()

View File

@@ -62,6 +62,47 @@ var testdataPermalinks = []struct {
p.title = "mytitle"
p.file = source.NewContentFileInfoFrom("/", "_index.md")
}, "/test-page/"},
// slug, title. // Section slug
{"/:sectionslug/", true, func(p *testPage) {
p.currentSection = &testPage{slug: "my-slug"}
}, "/my-slug/"},
// slug, title. // Section slugs
{"/:sectionslugs/", true, func(p *testPage) {
// Set up current section with ancestors
currentSection := &testPage{
slug: "c-slug",
kind: "section",
ancestors: Pages{
&testPage{slug: "b-slug", kind: "section"},
&testPage{slug: "a-slug", kind: "section"},
},
}
p.currentSection = currentSection
}, "/a-slug/b-slug/c-slug/"},
// slice: slug, title.
{"/:sectionslugs[0]/:sectionslugs[last]/", true, func(p *testPage) {
currentSection := &testPage{
slug: "c-slug",
kind: "section",
ancestors: Pages{
&testPage{slug: "b-slug", kind: "section"},
&testPage{slug: "a-slug", kind: "section"},
},
}
p.currentSection = currentSection
}, "/a-slug/c-slug/"},
// slice: slug, title.
{"/:sectionslugs[last]/", true, func(p *testPage) {
currentSection := &testPage{
slug: "c-slug",
kind: "section",
ancestors: Pages{
&testPage{slug: "b-slug", kind: "section"},
&testPage{slug: "a-slug", kind: "section"},
},
}
p.currentSection = currentSection
}, "/c-slug/"},
// Failures
{"/blog/:fred", false, nil, ""},
{"/:year//:title", false, nil, ""},

View File

@@ -111,6 +111,7 @@ type testPage struct {
currentSection *testPage
sectionEntries []string
ancestors Pages
}
func (p *testPage) Aliases() []string {
@@ -202,7 +203,12 @@ func (p *testPage) Filename() string {
}
func (p *testPage) FirstSection() Page {
panic("testpage: not implemented")
// Return the current section for regular pages
// For section pages, this would be the section itself
if p.currentSection != nil {
return p.currentSection
}
return p // If no current section, assume this page is the section
}
func (p *testPage) FuzzyWordCount(context.Context) int {
@@ -262,7 +268,7 @@ func (p *testPage) IsDraft() bool {
}
func (p *testPage) IsHome() bool {
panic("testpage: not implemented")
return p.kind == "home"
}
func (p *testPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool {
@@ -278,7 +284,7 @@ func (p *testPage) IsPage() bool {
}
func (p *testPage) IsSection() bool {
panic("testpage: not implemented")
return p.kind == "section"
}
func (p *testPage) IsTranslated() bool {
@@ -286,7 +292,7 @@ func (p *testPage) IsTranslated() bool {
}
func (p *testPage) Ancestors() Pages {
panic("testpage: not implemented")
return p.ancestors
}
func (p *testPage) Keywords() []string {