Repository: gohugoio/hugo (language: go)

Issue:
Add Permalink option for section pages
I have something like the following option in the config.toml:

```
[permalinks]
posts = "/new-posts/:filename/"
```

If I visit `http://localhost:1313/new-posts/post-name/` I get the intended page. But if I visit `http://localhost:1313/new-posts/` I don't get the list page, as I would expect. Instead, I get nothing at all.  I still have to go to `http://localhost:1313/posts/` to get the list page.

The version I'm using:

```
hugo v0.83.1+extended linux/amd64 BuildDate=unknown
```

I'm not sure if this behavior is intended. But it would be a good idea to have the ability to change the URL of the list pages using the permalinks option as well. Right now, I have to add the `url` parameter in the front matter of the `_index.md` to make the list page have the different url.


Discussion / hints:
>I'm not sure if this behavior is intended.

It is.
I see. Maybe this should be a feature request, then? It would be quite helpful for me at least to be able to change the URL of the list pages using some option similar to the `permalinks` option.
>I see. Maybe this should be a feature request, then? 

It behaves like it is for a reason -- I don't see how you could translate the permalink config to work for list pages, but I would be happy to be proven wrong.
I don't really know how it works behind the scenes. But we already have the ability to change the URL of list pages. We just have to use the `url` parameter in their front matter. The "problem" I'm having is that I want to change the whole folder to a different URL, so, on top of adding the `permalinks` option, I also have to add the `url` in all `_index.md` files in that folder and subfolders. The proposal would be to have an option to add to the config.toml that does this job of "adding the `url` option to all list pages". Not a huge problem really, but it would be convenient.
So, on the technical side this isn't a problem. 

But it's a permalink config, so they should be ... permanent. And the only permanent part of a section is the ... section path. 

Given 2 common  examples (assuming no date is set in section front matter):

```
[permalinks]
  "posts" = "/:year/:month/:filename/"
```

```
[permalinks]
  posts = "/:06/:1/:2/:title/"
```

How stable would the above be? And would it be a good URL?
Right. I suppose it would be necessary to have different options for single pages vs. list pages. Something like the following for list pages:

```
[permalinks.sections]
posts = "/some/new/path/"
```

It could support the `:year`, `:month` and such as well, even if these options would not be terribly useful in regular use cases. Then the current `permalinks` option could stay as it is, and this new option would change the url of the list pages.
@writeonlycode yes, something like that could work, but we need to somehow rework the existing `permalinks` struct in a backwards compatible way (we're not adding something ... else)... I suggest we somehow use the page `Kind`. I'm not sure if this is even valid TOML, but:

```toml
[permalinks]
[permalinks.section]
posts = "/some/new/path/"
[permalinks.page]
posts = "/:06/:1/:2/:title/"
```




That looks good! And it is valid TOML as far as I can see. I suppose the assumption should be that if no kind is specified, it defaults to single pages, so the following two should be equivalent:

```
[permalinks]
posts = "/:06/:1/:2/:title/"
```

```
[permalinks]
[permalinks.page]
posts = "/:06/:1/:2/:title/"
```

In this way, it will be backwards compatible. And in addition to those, we could have the `section` option, to accommodate the new behavior:

```
[permalinks]
[permalinks.section]
posts = "/some/new/path/"
```

This looks like a good way to go!
Another way / syntax to handle the `section` case would be that the value of the keys in `[permalinks]` can be either a string `""` or an array `["", ""]`.

If an array is specified :
- the first string define the path of `page` (or `term` if it's a taxonomy).
- The second define the `section` path.

Current version:
```toml
[permalinks]
posts = "/:06/:1/:2/:title/"
```

Proposition:
```toml
[permalinks]
# Classic way
categories = "/projects/:slug/"
# Advance way to define also the section path
posts = [ "/:06/:1/:2/:title/", "/articles/" ]
```
This would be very helpful, indeed. I am facing the same situation in a multilingual setup.
At least there would be only _one_ place to define the translated section name.
Currently I have to define the translation both in the configuration for the pages and in the list page in the `url` frontmatter entry.

config.yml
```yaml
languages:
  de:
    permalinks:
      offer: /angebot/:slug/
```

offer/_index.md
```yaml
---
weight: 30
title: Angebot
url: /angebot/
---
```

This is doable, but shifts parts of the translation into the configuration, which is not optimal.

For multilingual content management it is _way_ easier to keep the path and filenames untranslated and translate the _target_ only. One of the reasons for that is that you need lots of `translationKey` entries if you simply translate the section path in the file system. The second reason is that most tools are capable of translating _file contents_ only, but not _file paths_. In these scenarios the limitation that the _target_ of a section cannot be changed at build time in hugo makes content maintenance much more complicated.

The translation tools I am aware of are capable of translating frontmatter and content (_file content_), but not the configuration. And they cannot translate path segments in the file tree of the generated output (_file path_).

Having that said, it would be even better to have an option where the `url` of list pages is simply inherited for all children, so _effectively_ a means to set _permalink_ from _frontmatter_ for list pages _and_ the children. But this would be a different _Enhancement_ (obviously of the sort that would change a _fundamental_ design decision as per https://gohugo.io/content-management/sections/) and I absolutely understand that these are not accepted these days.

+1 for @BenSouchet 's proposal, because this structure keeps the multiple options in one place _per section_.


 
> That looks good! And it is valid TOML as far as I can see. I suppose the assumption should be that if no kind is specified, it defaults to single pages, so the following two should be equivalent:
> 
> ```
> [permalinks]
> posts = "/:06/:1/:2/:title/"
> ```
> 
> ```
> [permalinks]
> [permalinks.page]
> posts = "/:06/:1/:2/:title/"
> ```
> 
> In this way, it will be backwards compatible. And in addition to those, we could have the `section` option, to accommodate the new behavior:
> 
> ```
> [permalinks]
> [permalinks.section]
> posts = "/some/new/path/"
> ```
> 
> This looks like a good way to go!

It makes a lot of sense that way. It would be great if this problem is fixed with a new update. 
any update?
I would love this feature. I like this solution: https://github.com/gohugoio/hugo/issues/8523#issuecomment-1192894337.
I'll add another use case for this FWIW:

I'm currently working with the mechanism espoused in [this discussion](https://discourse.gohugo.io/t/29161).  After jumping through a few hoops (mostly media related), it works fairly well -- except that I've also set up non-default permalinks, and none of my multi-page posts appear in the correct location unless I deliberately configure their front matter, which is very inconvenient.

I can see other potential uses, but this is the concrete one I'm fighting with *literally right now*.  😀
It is a daily fight for me, as most of my Hugo websites are multilingual and my front matter is accessible to my clients/content editors. Therefore I would love to have a way to configure this properly.
> @writeonlycode yes, something like that could work, but we need to somehow rework the existing `permalinks` struct in a backwards compatible way (we're not adding something ... else)... I suggest we somehow use the page `Kind`. I'm not sure if this is even valid TOML, but:
> 
> ```toml
> [permalinks]
> [permalinks.section]
> posts = "/some/new/path/"
> [permalinks.page]
> posts = "/:06/:1/:2/:title/"
> ```


has this been implemented yet?
> > @writeonlycode yes, something like that could work, but we need to somehow rework the existing `permalinks` struct in a backwards compatible way (we're not adding something ... else)... I suggest we somehow use the page `Kind`. I'm not sure if this is even valid TOML, but:
> > ```toml
> > [permalinks]
> > [permalinks.section]
> > posts = "/some/new/path/"
> > [permalinks.page]
> > posts = "/:06/:1/:2/:title/"
> > ```
> 
> has this been implemented yet?

No, just a suggestion. If developers want to make this feature, they will be inspired by the ideas here. Ultimately, if the update comes, it's up to the developer to code this solution.

Fix patch:
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index ade7ea1be72..8bb0a156bfd 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -150,7 +150,7 @@ type Config struct {
 	Minify minifiers.MinifyConfig `mapstructure:"-"`
 
 	// Permalink configuration.
-	Permalinks map[string]string `mapstructure:"-"`
+	Permalinks map[string]map[string]string `mapstructure:"-"`
 
 	// Taxonomy configuration.
 	Taxonomies map[string]string `mapstructure:"-"`
diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go
index c6faf571d34..858a91f937b 100644
--- a/config/allconfig/alldecoders.go
+++ b/config/allconfig/alldecoders.go
@@ -199,7 +199,48 @@ var allDecoderSetups = map[string]decodeWeight{
 	"permalinks": {
 		key: "permalinks",
 		decode: func(d decodeWeight, p decodeConfig) error {
-			p.c.Permalinks = maps.CleanConfigStringMapString(p.p.GetStringMapString(d.key))
+			p.c.Permalinks = make(map[string]map[string]string)
+
+			p.c.Permalinks["page"] = make(map[string]string)
+			p.c.Permalinks["section"] = make(map[string]string)
+			p.c.Permalinks["taxonomy"] = make(map[string]string)
+			p.c.Permalinks["term"] = make(map[string]string)
+
+			config := maps.CleanConfigStringMap(p.p.GetStringMap(d.key))
+			for k, v := range config {
+				switch v := v.(type) {
+				case string:
+					// [permalinks]
+					//   key = '...'
+
+					// To sucessfully be backward compatible, "default" patterns need to be set for both page and term
+					p.c.Permalinks["page"][k] = v;
+					p.c.Permalinks["term"][k] = v;
+				
+				case maps.Params:
+					// [permalinks.key]
+					//   xyz = ???
+
+					if (k == "page") || (k == "section") || (k == "taxonomy") || (k == "term") {
+						// TODO: warn if we overwrite an already set value
+						for k2, v2 := range v {
+							switch v2 := v2.(type) {
+							case string:
+								p.c.Permalinks[k][k2] = v2
+							
+							default:
+								return fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k)
+							}
+						}
+					} else {
+						return fmt.Errorf("permalinks configuration only allows per-kind configuration 'page', 'section', 'taxonomy' and 'term'; unknown kind: %q", k)
+					}
+
+				default:
+					return fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q", v, k)
+				}
+			}
+
 			return nil
 		},
 	},
diff --git a/hugofs/files/classifier.go b/hugofs/files/classifier.go
index 09b239c21a9..5690795ed71 100644
--- a/hugofs/files/classifier.go
+++ b/hugofs/files/classifier.go
@@ -93,6 +93,7 @@ const (
 	ContentClassBranch  ContentClass = "branch"
 	ContentClassFile    ContentClass = "zfile" // Sort below
 	ContentClassContent ContentClass = "zcontent"
+	ContentClassZero    ContentClass = "zero"  // Special value for zeroFile
 )
 
 func (c ContentClass) IsBundle() bool {
diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go
index 72eac3182a2..f98531c1085 100644
--- a/hugolib/page__paths.go
+++ b/hugolib/page__paths.go
@@ -18,6 +18,7 @@ import (
 	"strings"
 
 	"github.com/gohugoio/hugo/helpers"
+	"github.com/gohugoio/hugo/hugofs/files"
 
 	"github.com/gohugoio/hugo/resources/page"
 )
@@ -108,14 +109,16 @@ func createTargetPathDescriptor(s *Site, p page.Page, pm *pageMeta) (page.Target
 	)
 
 	d := s.Deps
+	classifier := files.ContentClassZero
 
 	if !p.File().IsZero() {
 		dir = p.File().Dir()
 		baseName = p.File().TranslationBaseName()
 		contentBaseName = p.File().ContentBaseName()
+		classifier = p.File().Classifier()
 	}
 
-	if baseName != contentBaseName {
+	if classifier == files.ContentClassLeaf {
 		// See https://github.com/gohugoio/hugo/issues/4870
 		// A leaf bundle
 		dir = strings.TrimSuffix(dir, contentBaseName+helpers.FilePathSeparator)
@@ -143,22 +146,26 @@ func createTargetPathDescriptor(s *Site, p page.Page, pm *pageMeta) (page.Target
 	desc.PrefixFilePath = s.getLanguageTargetPathLang(alwaysInSubDir)
 	desc.PrefixLink = s.getLanguagePermalinkLang(alwaysInSubDir)
 
-	// Expand only page.KindPage and page.KindTaxonomy; don't expand other Kinds of Pages
-	// like page.KindSection or page.KindTaxonomyTerm because they are "shallower" and
-	// the permalink configuration values are likely to be redundant, e.g.
-	// naively expanding /category/:slug/ would give /category/categories/ for
-	// the "categories" page.KindTaxonomyTerm.
-	if p.Kind() == page.KindPage || p.Kind() == page.KindTerm {
-		opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p)
-		if err != nil {
-			return desc, err
-		}
+	opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p)
+	if err != nil {
+		return desc, err
+	}
 
-		if opath != "" {
-			opath, _ = url.QueryUnescape(opath)
-			desc.ExpandedPermalink = opath
+	if opath != "" {
+		opath, _ = url.QueryUnescape(opath)
+		if strings.HasSuffix(opath, "//") {
+			// When rewriting the _index of the section the permalink config is applied to,
+			// we get douple slashes at the end sometimes; clear them up here
+			opath = strings.TrimSuffix(opath, "/")
 		}
 
+		desc.ExpandedPermalink = opath
+
+		if !p.File().IsZero() {
+			s.Log.Debugf("Set expanded permalink path for %s %s to %#v", p.Kind(), p.File().Path(), opath)
+		} else {
+			s.Log.Debugf("Set expanded permalink path for %s in %v to %#v", p.Kind(), desc.Sections, opath)
+		}
 	}
 
 	return desc, nil
diff --git a/resources/page/page_generate/generate_page_wrappers.go b/resources/page/page_generate/generate_page_wrappers.go
index f4b40f71763..c034bc00269 100644
--- a/resources/page/page_generate/generate_page_wrappers.go
+++ b/resources/page/page_generate/generate_page_wrappers.go
@@ -221,7 +221,7 @@ func generateFileIsZeroWrappers(c *codegen.Inspector) error {
 	methods := c.MethodsFromTypes([]reflect.Type{reflect.TypeOf((*source.File)(nil)).Elem()}, nil)
 
 	for _, m := range methods {
-		if m.Name == "IsZero" {
+		if m.Name == "IsZero" || m.Name == "Classifier" {
 			continue
 		}
 		fmt.Fprint(&buff, m.DeclarationNamed("zeroFile"))
@@ -255,6 +255,11 @@ func (zeroFile) IsZero() bool {
 	return true
 }
 
+func (z zeroFile) Classifier() files.ContentClass {
+	z.log.Warnln(".File.Classifier on zero object. Wrap it in if or with: {{ with .File }}{{ .Classifier }}{{ end }}")
+	return files.ContentClassZero
+}
+
 %s
 
 `, header, importsString(pkgImports), buff.String())
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
index 3dfc3693766..ab418a20b9e 100644
--- a/resources/page/permalinks.go
+++ b/resources/page/permalinks.go
@@ -26,6 +26,7 @@ import (
 	"errors"
 
 	"github.com/gohugoio/hugo/helpers"
+
 )
 
 // PermalinkExpander holds permalin mappings per section.
@@ -35,7 +36,7 @@ type PermalinkExpander struct {
 	// to be used to replace that tag.
 	knownPermalinkAttributes map[string]pageToPermaAttribute
 
-	expanders map[string]func(Page) (string, error)
+	expanders map[string]map[string]func(Page) (string, error)
 
 	urlize func(uri string) string
 }
@@ -68,7 +69,7 @@ func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
 
 // NewPermalinkExpander creates a new PermalinkExpander configured by the given
 // urlize func.
-func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]string) (PermalinkExpander, error) {
+func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]map[string]string) (PermalinkExpander, error) {
 	p := PermalinkExpander{urlize: urlize}
 
 	p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
@@ -87,12 +88,15 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]st
 		"filename":       p.pageToPermalinkFilename,
 	}
 
-	e, err := p.parse(patterns)
-	if err != nil {
-		return p, err
-	}
+	p.expanders = make(map[string]map[string]func(Page) (string, error))
 
-	p.expanders = e
+	for kind, patterns := range patterns {
+		e, err := p.parse(patterns)
+		if err != nil {
+			return p, err
+		}
+		p.expanders[kind] = e
+	}
 
 	return p, nil
 }
@@ -100,7 +104,13 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]st
 // Expand expands the path in p according to the rules defined for the given key.
 // If no rules are found for the given key, an empty string is returned.
 func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
-	expand, found := l.expanders[key]
+	expanders, found := l.expanders[p.Kind()]
+
+	if !found {
+		return "", nil
+	}
+
+	expand, found := expanders[key]
 
 	if !found {
 		return "", nil
@@ -242,6 +252,10 @@ func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string
 
 // pageToPermalinkTitle returns the URL-safe form of the title
 func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) {
+	if p.File().TranslationBaseName() == "_index" {
+		return "", nil
+	}
+
 	return l.urlize(p.Title()), nil
 }
 
@@ -252,6 +266,8 @@ func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, er
 		// Page bundles; the directory name will hopefully have a better name.
 		dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator)
 		_, name = filepath.Split(dir)
+	} else if name == "_index" {
+		return "", nil
 	}
 
 	return l.urlize(name), nil
diff --git a/resources/page/zero_file.autogen.go b/resources/page/zero_file.autogen.go
index 72d98998ec2..979f92a0f0e 100644
--- a/resources/page/zero_file.autogen.go
+++ b/resources/page/zero_file.autogen.go
@@ -18,6 +18,7 @@ package page
 import (
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/hugofs"
+	"github.com/gohugoio/hugo/hugofs/files"
 	"github.com/gohugoio/hugo/source"
 )
 
@@ -34,6 +35,11 @@ func (zeroFile) IsZero() bool {
 	return true
 }
 
+func (z zeroFile) Classifier() files.ContentClass {
+	z.log.Warnln(".File.Classifier on zero object. Wrap it in if or with: {{ with .File }}{{ .Classifier }}{{ end }}")
+	return files.ContentClassZero
+}
+
 func (z zeroFile) Path() (o0 string) {
 	z.log.Warnln(".File.Path on zero object. Wrap it in if or with: {{ with .File }}{{ .Path }}{{ end }}")
 	return
diff --git a/source/fileInfo.go b/source/fileInfo.go
index c58a0c3b908..03bb1b16cb9 100644
--- a/source/fileInfo.go
+++ b/source/fileInfo.go
@@ -92,6 +92,9 @@ type FileWithoutOverlap interface {
 	// if file is a leaf bundle.
 	ContentBaseName() string
 
+	// Classifier is the ContentClass of the file
+	Classifier() files.ContentClass
+
 	// UniqueID is the MD5 hash of the file's path and is for most practical applications,
 	// Hugo content files being one of them, considered to be unique.
 	UniqueID() string
@@ -170,6 +173,11 @@ func (fi *FileInfo) ContentBaseName() string {
 	return fi.contentBaseName
 }
 
+// Classifier is the ContentClass of the file
+func (fi *FileInfo) Classifier() files.ContentClass {
+	return fi.classifier;
+}
+
 // Section returns a file's section.
 func (fi *FileInfo) Section() string {
 	fi.init()


Test patch:
diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go
index b9c0ca9cb48..24eb27f5b6f 100644
--- a/resources/page/permalinks_test.go
+++ b/resources/page/permalinks_test.go
@@ -69,6 +69,7 @@ func TestPermalinkExpansion(t *testing.T) {
 	page.date = d
 	page.section = "blue"
 	page.slug = "The Slug"
+	page.kind = "page"
 
 	for _, item := range testdataPermalinks {
 		if !item.valid {
@@ -79,8 +80,10 @@ func TestPermalinkExpansion(t *testing.T) {
 		name := specNameCleaner.ReplaceAllString(item.spec, "")
 
 		c.Run(name, func(c *qt.C) {
-			patterns := map[string]string{
-				"posts": item.spec,
+			patterns := map[string]map[string]string{
+				"page": {
+					"posts": item.spec,
+				},
 			}
 			expander, err := NewPermalinkExpander(urlize, patterns)
 			c.Assert(err, qt.IsNil)
@@ -103,14 +106,18 @@ func TestPermalinkExpansionMultiSection(t *testing.T) {
 	page.date = d
 	page.section = "blue"
 	page.slug = "The Slug"
+	page.kind = "page"
 
 	page_slug_fallback := newTestPageWithFile("/page-filename/index.md")
 	page_slug_fallback.title = "Page Title"
-
-	permalinksConfig := map[string]string{
-		"posts":   "/:slug",
-		"blog":    "/:section/:year",
-		"recipes": "/:slugorfilename",
+	page_slug_fallback.kind = "page"
+
+	permalinksConfig := map[string]map[string]string{
+		"page": {
+			"posts":   "/:slug",
+			"blog":    "/:section/:year",
+			"recipes": "/:slugorfilename",
+		},
 	}
 	expander, err := NewPermalinkExpander(urlize, permalinksConfig)
 	c.Assert(err, qt.IsNil)
@@ -137,8 +144,10 @@ func TestPermalinkExpansionConcurrent(t *testing.T) {
 
 	c := qt.New(t)
 
-	permalinksConfig := map[string]string{
-		"posts": "/:slug/",
+	permalinksConfig := map[string]map[string]string{
+		"page": {
+			"posts": "/:slug/",
+		},
 	}
 
 	expander, err := NewPermalinkExpander(urlize, permalinksConfig)
@@ -151,6 +160,7 @@ func TestPermalinkExpansionConcurrent(t *testing.T) {
 		go func(i int) {
 			defer wg.Done()
 			page := newTestPage()
+			page.kind = "page"
 			for j := 1; j < 20; j++ {
 				page.slug = fmt.Sprintf("slug%d", i+j)
 				expanded, err := expander.Expand("posts", page)
@@ -209,9 +219,12 @@ func BenchmarkPermalinkExpand(b *testing.B) {
 	page.title = "Hugo Rocks"
 	d, _ := time.Parse("2006-01-02", "2019-02-28")
 	page.date = d
+	page.kind = "page"
 
-	permalinksConfig := map[string]string{
-		"posts": "/:year-:month-:title",
+	permalinksConfig := map[string]map[string]string{
+		"page": {
+			"posts": "/:year-:month-:title",
+		},
 	}
 	expander, err := NewPermalinkExpander(urlize, permalinksConfig)
 	if err != nil {