]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
resources/page: Add :sectionslug and :sectionslugs permalink tokens
authorDustin Fischer <57132358+DustinFischer@users.noreply.github.com>
Sat, 23 Aug 2025 10:35:21 +0000 (12:35 +0200)
committerGitHub <noreply@github.com>
Sat, 23 Aug 2025 10:35:21 +0000 (12:35 +0200)
Add slugified section permalink tokens with fallback behavior and slice syntax support.

Fixes #13788

docs/content/en/_common/permalink-tokens.md
resources/page/permalinks.go
resources/page/permalinks_integration_test.go
resources/page/permalinks_test.go
resources/page/testhelpers_test.go

index 4aec68fb8df7ee0121afba805ab2a001ab8b21a7..e8443101a37251d100918df8d5b7d44677ab2821 100644 (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.
 
index f8cbcd62c00cde6230ffeeee4808749c4dfd372a..1d6078b4ab4c3d132a691b7791f31b243427f46b 100644 (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
index c865e27048e6fa0c66968344b7014114e1279eb8..74df388208fb1c625838b7daa3088a36b52062e9 100644 (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()
 
index 191259252ddc898a7a8400e5065d01331b3c37ef..90d6784007576b9b5ad96523302fe3d958b9c8e4 100644 (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, ""},
index 5e1ec9a5892480273cbc6e2bca5edd6d17df5eeb..b9ccee00495b0843c9e8ce21cc5d60615330cb06 100644 (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 {