Add slugified section permalink tokens with fallback behavior and slice syntax support.
Fixes #13788
`: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.
}, 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
"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,
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
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
[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/'
-- 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"
// 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/|")
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/"},
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()
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, ""},
currentSection *testPage
sectionEntries []string
+ ancestors Pages
}
func (p *testPage) Aliases() []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 {
}
func (p *testPage) IsHome() bool {
- panic("testpage: not implemented")
+ return p.kind == "home"
}
func (p *testPage) IsMenuCurrent(menuID string, inme *navigation.MenuEntry) bool {
}
func (p *testPage) IsSection() bool {
- panic("testpage: not implemented")
+ return p.kind == "section"
}
func (p *testPage) IsTranslated() bool {
}
func (p *testPage) Ancestors() Pages {
- panic("testpage: not implemented")
+ return p.ancestors
}
func (p *testPage) Keywords() []string {