Repository: gohugoio/hugo (language: go)

Issue:
Add permalinks tokens contentbasename and contentbasenameorslug
<!--
Please do not use the issue queue for questions or troubleshooting. Unless you are certain that your issue is a software defect, use the forum:

https://discourse.gohugo.io
-->

<!-- Please answer these questions before submitting your issue. Thanks! -->

### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.120.4-f11bca5fec2ebb3a02727fb2a5cfb08da96fd9df+extended darwin/arm64 BuildDate=2023-11-08T11:18:07Z VendorInfo=brew
</pre>

### Does this issue reproduce with the latest release?

1. Create a `categories` taxonomy.
2. Create file `/categories/a/_index.md` and `/categories/b/_index.md`
3. Create posts for those categories.
4. Add the following to your config:

```
[permalinks]
  categories = '/:filename/'
```

All categories will be concurrently built to `/index.html`, which leads me to believe that `:filename` is empty for them. 


Discussion / hints:
> /categories/a/_index.md and /categories/b/_index.md

Both filenames are _index.md. Not sure why you would want to do this. Seems like you want this instead:

```
[permalinks.term]
categories = '/:slug/'
```


@jmooring for other files, `:filename` is filled up with their directory name. I have the following rule:

```
posts = '/:year/:month/:day/:filename/'
```

And the post at `posts/2023/distributed-tracing-with-opentelemetry/index.md` will be published at: 

`/2023/04/18/distributed-tracing-with-opentelemetry/` (using the date in the frontmatter).

If it works for posts, why doesn't it work for categories?

I want the name attributed to that entry. Whether it is a directory entry of just a markdown file, it should not matter. You can see the source here: https://github.com/hacdias/hacdias.com

I wanted the categories to also use the disk name without me having to define a manual slug and not be dependent on the title, just like the posts.
Assuming you have files backing these categories, I don't see how they should behave differently.

But I see this in your config, which does not match your expectations:


```toml
[permalinks]
  tags = '/tags/:title/'
  categories = '/:title/'
  posts = '/:year/:month/:day/:filename/'
```

Also, I suspect that `:slugorfilename` may be a better option.


 @bep 

> But I see this in your config, which does not match your expectations:

I want to change to `categories = '/:filename/'`. But if I do that, it will all concurrently build to `/index.html` instead of to `/:filename/index.html`. Feel free to clone my repo, replacing that and building to see. All categories have a matching file.
https://github.com/gohugoio/hugo/blob/68e95327f7be941c44c256d2dac74ea96e731674/resources/page/permalinks.go#L265-L277

Examining the above, the `:filename` token is limited to kind `page`. Before leaf bundles were introduced, "filename" was an accurate description. Then we introduced leaf bundles, and "filename" became either the file name or the directory name, but was still limited to kind `page`.

To avoid a breaking change, I suggest that we add a `:contentbasename` token.

> ContentBaseName: If the page is a branch or leaf bundle, the name of the containing directory, else the TranslationBaseName.
That's interesting and looks like the source of the problem. I'm not sure that introducing `:contentbasename` is necessary though: is it a breaking change, or a bug? It doesn't seem likely to me that someone is using `:filename` for taxonomies if the current behaviour is that it won't work.

Whatever is done, I think the documentation should be clarified: https://gohugo.io/content-management/urls/#tokens
> I'm not sure that introducing :contentbasename is necessary

1. Changing the meaning of `:filename` may change some sites. I've been bitten by the "doesn't seem likely to me" assumption a few times, and there are some ugly and unexpected configs in the wild.
2. The word "filename" was once an accurate description, but is has not been accurate for a long time. This is an opportunity to clean that up, using the same language we use [elsewhere](https://gohugo.io/methods/page/file). And maybe we can deprecate `:filename` at some point.

Other considerations...

In most cases taxonomy terms are not backed by a file, so should `:contentbasename` fall back to the urlized title? If it doesn't, this will result in missing term pages when terms are not backed by a file:

```text
[permalinks]
categories = '/:contentbasename'
```

We have a `:slugorfilename` token, so it seems like we'd want `:slugorcontentbasename` too... yuck


While updating <https://gohugo.io/content-management/urls/#tokens> to match current behavior, adding exceptions to the documentation is clumsy and a bit convoluted (e.g., "This applies to xxx but not to yyy.").

I think we should:

1. Add a `:contentbasename` token that returns the [`File.ContentBaseName`](https://gohugo.io/methods/page/file/#contentbasename) for _all_ page kinds, not just limited to `page`.
2. Add a `:contentbasenameorslug` token that applies to _all_ page kinds, not just limited to `page`.
3. Deprecate `:filename` because [`File.Filename` ](https://gohugo.io/methods/page/file/#filename) is something different (the absolute file path), and to me the tokens should be symmetrical with the `File.xxx` names.

@bep I would appreciate it if you would (a) close this as "won't fix", (b) reclassify it as enhancement, or (c) comment with recommended changes. Thanks.

I'm pretty sure the current token implementation predates the `File.ContentBaseName` implementation.

Hi! Is there a plan to address this? I just made a PR for it: https://github.com/gohugoio/hugo/pull/13382

Fix patch:
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
index ece10bb40e5..953f4a2e8e0 100644
--- a/resources/page/permalinks.go
+++ b/resources/page/permalinks.go
@@ -79,19 +79,21 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
 	}
 
 	p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
-		"year":           p.pageToPermalinkDate,
-		"month":          p.pageToPermalinkDate,
-		"monthname":      p.pageToPermalinkDate,
-		"day":            p.pageToPermalinkDate,
-		"weekday":        p.pageToPermalinkDate,
-		"weekdayname":    p.pageToPermalinkDate,
-		"yearday":        p.pageToPermalinkDate,
-		"section":        p.pageToPermalinkSection,
-		"sections":       p.pageToPermalinkSections,
-		"title":          p.pageToPermalinkTitle,
-		"slug":           p.pageToPermalinkSlugElseTitle,
-		"slugorfilename": p.pageToPermalinkSlugElseFilename,
-		"filename":       p.pageToPermalinkFilename,
+		"year":                  p.pageToPermalinkDate,
+		"month":                 p.pageToPermalinkDate,
+		"monthname":             p.pageToPermalinkDate,
+		"day":                   p.pageToPermalinkDate,
+		"weekday":               p.pageToPermalinkDate,
+		"weekdayname":           p.pageToPermalinkDate,
+		"yearday":               p.pageToPermalinkDate,
+		"section":               p.pageToPermalinkSection,
+		"sections":              p.pageToPermalinkSections,
+		"title":                 p.pageToPermalinkTitle,
+		"slug":                  p.pageToPermalinkSlugElseTitle,
+		"slugorfilename":        p.pageToPermalinkSlugElseFilename,
+		"filename":              p.pageToPermalinkFilename,
+		"contentbasename":       p.pageToPermalinkContentBaseName,
+		"slugorcontentbasename": p.pageToPermalinkSlugOrContentBaseName,
 	}
 
 	p.expanders = make(map[string]map[string]func(Page) (string, error))
@@ -307,6 +309,23 @@ func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, er
 	return p.CurrentSection().SectionsPath(), nil
 }
 
+// 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().BaseNameNoIdentifier()), nil
+}
+
+// pageToPermalinkSlugOrContentBaseName returns the URL-safe form of the slug, content base name.
+func (l PermalinkExpander) pageToPermalinkSlugOrContentBaseName(p Page, a string) (string, error) {
+	if p.Slug() != "" {
+		return l.urlize(p.Slug()), nil
+	}
+	name, err := l.pageToPermalinkContentBaseName(p, a)
+	if err != nil {
+		return "", nil
+	}
+	return name, nil
+}
+
 func (l PermalinkExpander) translationBaseName(p Page) string {
 	if p.File() == nil {
 		return ""
diff --git a/source/fileInfo.go b/source/fileInfo.go
index 8403c808894..dfa5cda26af 100644
--- a/source/fileInfo.go
+++ b/source/fileInfo.go
@@ -22,7 +22,7 @@ import (
 	"github.com/gohugoio/hugo/common/hashing"
 	"github.com/gohugoio/hugo/common/hugo"
 	"github.com/gohugoio/hugo/common/paths"
-	"github.com/gohugoio/hugo/media"
+	"github.com/gohugoio/hugo/hugofs/files"
 
 	"github.com/gohugoio/hugo/common/hugio"
 
@@ -132,11 +132,17 @@ func (fi *File) p() *paths.Path {
 	return fi.fim.Meta().PathInfo.Unnormalized()
 }
 
+var contentPathParser = &paths.PathParser{
+	IsContentExt: func(ext string) bool {
+		return true
+	},
+}
+
 // Used in tests.
-func NewFileInfoFrom(path, filename string) *File {
+func NewContentFileInfoFrom(path, filename string) *File {
 	meta := &hugofs.FileMeta{
 		Filename: filename,
-		PathInfo: media.DefaultPathParser.Parse("", filepath.ToSlash(path)),
+		PathInfo: contentPathParser.Parse(files.ComponentFolderContent, filepath.ToSlash(path)),
 	}
 
 	return NewFileInfo(hugofs.NewFileMetaInfo(nil, meta))


Test patch:
diff --git a/resources/page/permalinks_integration_test.go b/resources/page/permalinks_integration_test.go
index 52bcd686080..6ef450edd0d 100644
--- a/resources/page/permalinks_integration_test.go
+++ b/resources/page/permalinks_integration_test.go
@@ -277,3 +277,69 @@ title: p2
 	// We strip colons from paths constructed by Hugo (they are not supported on Windows).
 	b.AssertFileExists("public/cd/p2/index.html", true)
 }
+
+func TestPermalinksContentbasenameContentAdapter(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+[permalinks]
+[permalinks.page]
+a = "/:slugorcontentbasename/"
+b = "/:sections/:contentbasename/"
+-- content/_content.gotmpl --
+{{ $.AddPage  (dict "kind" "page" "path" "a/b/contentbasename1" "title" "My A Page No Slug")  }}
+{{ $.AddPage (dict "kind" "page" "path" "a/b/contentbasename2" "slug" "myslug"  "title" "My A Page With Slug")  }}
+ {{ $.AddPage  (dict "kind" "section" "path" "b/c" "title" "My B Section")  }}
+{{ $.AddPage  (dict "kind" "page" "path" "b/c/contentbasename3" "title" "My B Page No Slug")  }}
+-- layouts/_default/single.html --
+{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}|
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/contentbasename1/index.html", "My A Page No Slug|/contentbasename1/|page|")
+	b.AssertFileContent("public/myslug/index.html", "My A Page With Slug|/myslug/|page|")
+}
+
+func TestPermalinksContentbasenameWithAndWithoutFile(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+[permalinks.section]
+a = "/mya/:contentbasename/"
+[permalinks.page]
+a = "/myapage/:contentbasename/"
+[permalinks.term]
+categories = "/myc/:slugorcontentbasename/"
+-- content/b/c/_index.md --
+---
+title: "C section"
+---
+-- content/a/b/index.md --
+---
+title: "My Title"
+categories: ["c1", "c2"]
+---
+-- content/categories/c2/_index.md --
+---
+title: "C2"
+slug: "c2slug"
+---
+-- layouts/_default/single.html --
+{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}|
+-- layouts/_default/list.html --
+{{ .Title }}|{{ .RelPermalink }}|{{ .Kind }}|
+`
+	b := hugolib.Test(t, files)
+
+	// Sections.
+	b.AssertFileContent("public/mya/a/index.html", "As|/mya/a/|section|")
+
+	// Pages.
+	b.AssertFileContent("public/myapage/b/index.html", "My Title|/myapage/b/|page|")
+
+	// Taxonomies.
+	b.AssertFileContent("public/myc/c1/index.html", "C1|/myc/c1/|term|")
+	b.AssertFileContent("public/myc/c2slug/index.html", "C2|/myc/c2slug/|term|")
+}
diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go
index 808521f42ef..191259252dd 100644
--- a/resources/page/permalinks_test.go
+++ b/resources/page/permalinks_test.go
@@ -22,6 +22,7 @@ import (
 	"time"
 
 	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/source"
 )
 
 // testdataPermalinks is used by a couple of tests; the expandsTo content is
@@ -29,30 +30,44 @@ import (
 var testdataPermalinks = []struct {
 	spec      string
 	valid     bool
+	withPage  func(p *testPage)
 	expandsTo string
 }{
-	{":title", true, "spf13-vim-3.0-release-and-new-website"},
-	{"/:year-:month-:title", true, "/2012-04-spf13-vim-3.0-release-and-new-website"},
-	{"/:year/:yearday/:month/:monthname/:day/:weekday/:weekdayname/", true, "/2012/97/04/April/06/5/Friday/"}, // Dates
-	{"/:section/", true, "/blue/"},                                  // Section
-	{"/:title/", true, "/spf13-vim-3.0-release-and-new-website/"},   // Title
-	{"/:slug/", true, "/the-slug/"},                                 // Slug
-	{"/:slugorfilename/", true, "/the-slug/"},                       // Slug or filename
-	{"/:filename/", true, "/test-page/"},                            // Filename
-	{"/:06-:1-:2-:Monday", true, "/12-4-6-Friday"},                  // Dates with Go formatting
-	{"/:2006_01_02_15_04_05.000", true, "/2012_04_06_03_01_59.000"}, // Complicated custom date format
-	{"/:sections/", true, "/a/b/c/"},                                // Sections
-	{"/:sections[last]/", true, "/c/"},                              // Sections
-	{"/:sections[0]/:sections[last]/", true, "/a/c/"},               // Sections
-	{"/\\:filename", true, "/:filename"},                            // Escape sequence
-	{"/special\\::slug/", true, "/special:the-slug/"},               // Escape sequence
-
+	{":title", true, nil, "spf13-vim-3.0-release-and-new-website"},
+	{"/:year-:month-:title", true, nil, "/2012-04-spf13-vim-3.0-release-and-new-website"},
+	{"/:year/:yearday/:month/:monthname/:day/:weekday/:weekdayname/", true, nil, "/2012/97/04/April/06/5/Friday/"}, // Dates
+	{"/:section/", true, nil, "/blue/"},                                                                            // Section
+	{"/:title/", true, nil, "/spf13-vim-3.0-release-and-new-website/"},                                             // Title
+	{"/:slug/", true, nil, "/the-slug/"},                                                                           // Slug
+	{"/:slugorfilename/", true, nil, "/the-slug/"},                                                                 // Slug or filename
+	{"/:filename/", true, nil, "/test-page/"},                                                                      // Filename
+	{"/:06-:1-:2-:Monday", true, nil, "/12-4-6-Friday"},                                                            // Dates with Go formatting
+	{"/:2006_01_02_15_04_05.000", true, nil, "/2012_04_06_03_01_59.000"},                                           // Complicated custom date format
+	{"/:sections/", true, nil, "/a/b/c/"},                                                                          // Sections
+	{"/:sections[last]/", true, nil, "/c/"},                                                                        // Sections
+	{"/:sections[0]/:sections[last]/", true, nil, "/a/c/"},                                                         // Sections
+	{"/\\:filename", true, nil, "/:filename"},                                                                      // Escape sequence
+	{"/special\\::slug/", true, nil, "/special:the-slug/"},
+	// contentbasename.                                                      // Escape sequence
+	{"/:contentbasename/", true, nil, "/test-page/"},
+	// slug, contentbasename.                                               // Content base name
+	{"/:slugorcontentbasename/", true, func(p *testPage) {
+		p.slug = ""
+	}, "/test-page/"},
+	{"/:slugorcontentbasename/", true, func(p *testPage) {
+		p.slug = "myslug"
+	}, "/myslug/"},
+	{"/:slugorcontentbasename/", true, func(p *testPage) {
+		p.slug = ""
+		p.title = "mytitle"
+		p.file = source.NewContentFileInfoFrom("/", "_index.md")
+	}, "/test-page/"},
 	// Failures
-	{"/blog/:fred", false, ""},
-	{"/:year//:title", false, ""},
-	{"/:TITLE", false, ""},      // case is not normalized
-	{"/:2017", false, ""},       // invalid date format
-	{"/:2006-01-02", false, ""}, // valid date format but invalid attribute name
+	{"/blog/:fred", false, nil, ""},
+	{"/:year//:title", false, nil, ""},
+	{"/:TITLE", false, nil, ""},      // case is not normalized
+	{"/:2017", false, nil, ""},       // invalid date format
+	{"/:2006-01-02", false, nil, ""}, // valid date format but invalid attribute name
 }
 
 func urlize(uri string) string {
@@ -65,21 +80,30 @@ func TestPermalinkExpansion(t *testing.T) {
 
 	c := qt.New(t)
 
-	page := newTestPageWithFile("/test-page/index.md")
-	page.title = "Spf13 Vim 3.0 Release and new website"
-	d, _ := time.Parse("2006-01-02 15:04:05", "2012-04-06 03:01:59")
-	page.date = d
-	page.section = "blue"
-	page.slug = "The Slug"
-	page.kind = "page"
+	newPage := func() *testPage {
+		page := newTestPageWithFile("/test-page/index.md")
+		page.title = "Spf13 Vim 3.0 Release and new website"
+		d, _ := time.Parse("2006-01-02 15:04:05", "2012-04-06 03:01:59")
+		page.date = d
+		page.section = "blue"
+		page.slug = "The Slug"
+		page.kind = "page"
+		// page.pathInfo
+		return page
+	}
 
-	for _, item := range testdataPermalinks {
+	for i, item := range testdataPermalinks {
 		if !item.valid {
 			continue
 		}
 
+		page := newPage()
+		if item.withPage != nil {
+			item.withPage(page)
+		}
+
 		specNameCleaner := regexp.MustCompile(`[\:\/\[\]]`)
-		name := specNameCleaner.ReplaceAllString(item.spec, "")
+		name := fmt.Sprintf("[%d] %s", i, specNameCleaner.ReplaceAllString(item.spec, "_"))
 
 		c.Run(name, func(c *qt.C) {
 			patterns := map[string]map[string]string{
diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go
index 8e6dfb79ac2..59b8cf0e8e1 100644
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -52,7 +52,7 @@ func newTestPage() *testPage {
 
 func newTestPageWithFile(filename string) *testPage {
 	filename = filepath.FromSlash(filename)
-	file := source.NewFileInfoFrom(filename, filename)
+	file := source.NewContentFileInfoFrom(filename, filename)
 
 	l, err := langs.NewLanguage(
 		"en",
@@ -67,9 +67,10 @@ func newTestPageWithFile(filename string) *testPage {
 	}
 
 	return &testPage{
-		params: make(map[string]any),
-		data:   make(map[string]any),
-		file:   file,
+		params:   make(map[string]any),
+		data:     make(map[string]any),
+		file:     file,
+		pathInfo: file.FileInfo().Meta().PathInfo,
 		currentSection: &testPage{
 			sectionEntries: []string{"a", "b", "c"},
 		},
@@ -90,7 +91,8 @@ type testPage struct {
 
 	fuzzyWordCount int
 
-	path string
+	path     string
+	pathInfo *paths.Path
 
 	slug string
 
@@ -406,7 +408,7 @@ func (p *testPage) Path() string {
 }
 
 func (p *testPage) PathInfo() *paths.Path {
-	panic("testpage: not implemented")
+	return p.pathInfo
 }
 
 func (p *testPage) Permalink() string {