From 12ace3ad5c60268978e25edb2919fa51c489a422 Mon Sep 17 00:00:00 2001 From: Dustin Fischer <57132358+DustinFischer@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:35:21 +0200 Subject: [PATCH] resources/page: Add :sectionslug and :sectionslugs permalink tokens Add slugified section permalink tokens with fallback behavior and slice syntax support. Fixes #13788 --- docs/content/en/_common/permalink-tokens.md | 6 ++ resources/page/permalinks.go | 53 +++++++++++ resources/page/permalinks_integration_test.go | 89 ++++++++++++++++++- resources/page/permalinks_test.go | 41 +++++++++ resources/page/testhelpers_test.go | 14 ++- 5 files changed, 198 insertions(+), 5 deletions(-) diff --git a/docs/content/en/_common/permalink-tokens.md b/docs/content/en/_common/permalink-tokens.md index 4aec68fb8..e8443101a 100644 --- a/docs/content/en/_common/permalink-tokens.md +++ b/docs/content/en/_common/permalink-tokens.md @@ -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. diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go index f8cbcd62c..1d6078b4a 100644 --- a/resources/page/permalinks.go +++ b/resources/page/permalinks.go @@ -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 diff --git a/resources/page/permalinks_integration_test.go b/resources/page/permalinks_integration_test.go index c865e2704..74df38820 100644 --- a/resources/page/permalinks_integration_test.go +++ b/resources/page/permalinks_integration_test.go @@ -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() diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go index 191259252..90d678400 100644 --- a/resources/page/permalinks_test.go +++ b/resources/page/permalinks_test.go @@ -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, ""}, diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go index 5e1ec9a58..b9ccee004 100644 --- a/resources/page/testhelpers_test.go +++ b/resources/page/testhelpers_test.go @@ -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 {