mirror of
https://github.com/gohugoio/hugo.git
synced 2025-09-02 22:52:51 +02:00
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:
@@ -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.
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -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, ""},
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user