Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/content/en/_common/permalink-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
53 changes: 53 additions & 0 deletions resources/page/permalinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
89 changes: 88 additions & 1 deletion resources/page/permalinks_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down Expand Up @@ -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"
Expand All @@ -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/|")
Expand All @@ -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/"},
Expand Down Expand Up @@ -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()

Expand Down
41 changes: 41 additions & 0 deletions resources/page/permalinks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""},
Expand Down
14 changes: 10 additions & 4 deletions resources/page/testhelpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ type testPage struct {

currentSection *testPage
sectionEntries []string
ancestors Pages
}

func (p *testPage) Aliases() []string {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -278,15 +284,15 @@ func (p *testPage) IsPage() bool {
}

func (p *testPage) IsSection() bool {
panic("testpage: not implemented")
return p.kind == "section"
}

func (p *testPage) IsTranslated() bool {
panic("testpage: not implemented")
}

func (p *testPage) Ancestors() Pages {
panic("testpage: not implemented")
return p.ancestors
}

func (p *testPage) Keywords() []string {
Expand Down
Loading