Repository: gohugoio/hugo (language: go)

Issue:
Allow permalinks configuration syntax to `url` Front Matter.
### Currently

Currently, batch permalink settings can only happen through the site's config's `permalinks` object. For every setting, it takes a content directory as key and a string that can include "template strings" syntax as value.

To set the permalink of the `/recipe` directory using the slug:

```yaml
permalinks:
  recipe: /delicious-recipe/:slug
```

That is limiting to batch editing content files of a root directory. The recent `:sections` templates brought more power to the desired permalink but did not fully resolve the limited "targeting" of the batch. For example, if your content files are in a subdirectory `content/cooking/recipe`, you would have no way (at least I did not find one) to target the files in the directory to batch assign a permalink structure.

### The proposal

Allow the `url` Front Matter key to use the same syntax as permalink's configuration.

```yaml
---
# content/cooking/recipe/_index.md
title: Delicious Recipes
url: /delicious-recipe
cascade:
  url: /delicious-recipe/:slug
---
```


Discussion / hints:
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Hugo team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
Sorry for the ... very late response. I like this.
This would be excellent.

Fix patch:
diff --git a/common/hreflect/helpers.go b/common/hreflect/helpers.go
index b5a8bacc929..5113a388613 100644
--- a/common/hreflect/helpers.go
+++ b/common/hreflect/helpers.go
@@ -268,7 +268,8 @@ func IsContextType(tp reflect.Type) bool {
 		return true
 	}
 
-	return isContextCache.GetOrCreate(tp, func() bool {
-		return tp.Implements(contextInterface)
+	isContext, _ := isContextCache.GetOrCreate(tp, func() (bool, error) {
+		return tp.Implements(contextInterface), nil
 	})
+	return isContext
 }
diff --git a/common/maps/cache.go b/common/maps/cache.go
index 7cd7410c216..0175974b5b4 100644
--- a/common/maps/cache.go
+++ b/common/maps/cache.go
@@ -40,22 +40,25 @@ func (c *Cache[K, T]) Get(key K) (T, bool) {
 }
 
 // GetOrCreate gets the value for the given key if it exists, or creates it if not.
-func (c *Cache[K, T]) GetOrCreate(key K, create func() T) T {
+func (c *Cache[K, T]) GetOrCreate(key K, create func() (T, error)) (T, error) {
 	c.RLock()
 	v, found := c.m[key]
 	c.RUnlock()
 	if found {
-		return v
+		return v, nil
 	}
 	c.Lock()
 	defer c.Unlock()
 	v, found = c.m[key]
 	if found {
-		return v
+		return v, nil
+	}
+	v, err := create()
+	if err != nil {
+		return v, err
 	}
-	v = create()
 	c.m[key] = v
-	return v
+	return v, nil
 }
 
 // Set sets the given key to the given value.
diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go
index d89388f81e0..6324b587125 100644
--- a/hugolib/page__paths.go
+++ b/hugolib/page__paths.go
@@ -141,6 +141,19 @@ func createTargetPathDescriptor(p *pageState) (page.TargetPathDescriptor, error)
 	desc.PrefixFilePath = s.getLanguageTargetPathLang(alwaysInSubDir)
 	desc.PrefixLink = s.getLanguagePermalinkLang(alwaysInSubDir)
 
+	if desc.URL != "" && strings.IndexByte(desc.URL, ':') >= 0 {
+		// Attempt to parse and expand an url
+		opath, err := d.ResourceSpec.Permalinks.ExpandPattern(desc.URL, p)
+		if err != nil {
+			return desc, err
+		}
+
+		if opath != "" {
+			opath, _ = url.QueryUnescape(opath)
+			desc.URL = opath
+		}
+	}
+
 	opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p)
 	if err != nil {
 		return desc, err
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
index 67c63c4b21f..05911f0eaa6 100644
--- a/resources/page/permalinks.go
+++ b/resources/page/permalinks.go
@@ -40,6 +40,8 @@ type PermalinkExpander struct {
 	expanders map[string]map[string]func(Page) (string, error)
 
 	urlize func(uri string) string
+
+	patternCache *maps.Cache[string, func(Page) (string, error)]
 }
 
 // Time for checking date formats. Every field is different than the
@@ -71,7 +73,10 @@ 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]map[string]string) (PermalinkExpander, error) {
-	p := PermalinkExpander{urlize: urlize}
+	p := PermalinkExpander{
+		urlize:       urlize,
+		patternCache: maps.NewCache[string, func(Page) (string, error)](),
+	}
 
 	p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
 		"year":           p.pageToPermalinkDate,
@@ -102,6 +107,16 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
 	return p, nil
 }
 
+// ExpandPattern expands the path in p with the specified expand pattern.
+func (l PermalinkExpander) ExpandPattern(pattern string, p Page) (string, error) {
+	expander, err := l.getOrParsePattern(pattern)
+	if err != nil {
+		return "", err
+	}
+
+	return expander(p)
+}
+
 // 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) {
@@ -129,17 +144,11 @@ func init() {
 	}
 }
 
-func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
-	expanders := make(map[string]func(Page) (string, error))
-
-	for k, pattern := range patterns {
-		k = strings.Trim(k, sectionCutSet)
-
+func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string, error), error) {
+	return l.patternCache.GetOrCreate(pattern, func() (func(Page) (string, error), error) {
 		if !l.validate(pattern) {
 			return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed}
 		}
-
-		pattern := pattern
 		matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
 
 		callbacks := make([]pageToPermaAttribute, len(matches))
@@ -157,7 +166,7 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa
 			callbacks[i] = callback
 		}
 
-		expanders[k] = func(p Page) (string, error) {
+		return func(p Page) (string, error) {
 			if matches == nil {
 				return pattern, nil
 			}
@@ -173,12 +182,25 @@ func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Pa
 				}
 
 				newField = strings.Replace(newField, replacement, newAttr, 1)
-
 			}
 
 			return newField, nil
+		}, nil
+	})
+}
+
+func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
+	expanders := make(map[string]func(Page) (string, error))
+
+	for k, pattern := range patterns {
+		k = strings.Trim(k, sectionCutSet)
+
+		expander, err := l.getOrParsePattern(pattern)
+		if err != nil {
+			return nil, err
 		}
 
+		expanders[k] = expander
 	}
 
 	return expanders, nil
diff --git a/tpl/templates/templates.go b/tpl/templates/templates.go
index 98b4b4c3873..0be44a013ae 100644
--- a/tpl/templates/templates.go
+++ b/tpl/templates/templates.go
@@ -90,14 +90,14 @@ func (ns *Namespace) DoDefer(ctx context.Context, id string, optsv any) string {
 
 	id = fmt.Sprintf("%s_%s%s", id, key, tpl.HugoDeferredTemplateSuffix)
 
-	_ = ns.deps.BuildState.DeferredExecutions.Executions.GetOrCreate(id,
-		func() *tpl.DeferredExecution {
+	_, _ = ns.deps.BuildState.DeferredExecutions.Executions.GetOrCreate(id,
+		func() (*tpl.DeferredExecution, error) {
 			return &tpl.DeferredExecution{
 				TemplateName: templateName,
 				Ctx:          ctx,
 				Data:         opts.Data,
 				Executed:     false,
-			}
+			}, nil
 		})
 
 	return id


Test patch:
diff --git a/hugolib/page_permalink_test.go b/hugolib/page_permalink_test.go
index bc89638d3ac..d8fd99d79e5 100644
--- a/hugolib/page_permalink_test.go
+++ b/hugolib/page_permalink_test.go
@@ -59,6 +59,8 @@ func TestPermalink(t *testing.T) {
 
 		// test URL overrides
 		{"x/y/z/boofar.md", "", "", "/z/y/q/", false, false, "/z/y/q/", "/z/y/q/"},
+		// test URL override with expands
+		{"x/y/z/boofar.md", "", "test", "/z/:slug/", false, false, "/z/test/", "/z/test/"},
 	}
 
 	for i, test := range tests {
diff --git a/resources/page/permalinks_integration_test.go b/resources/page/permalinks_integration_test.go
index 9a76ac60226..2b9e878b168 100644
--- a/resources/page/permalinks_integration_test.go
+++ b/resources/page/permalinks_integration_test.go
@@ -193,3 +193,42 @@ List.
 	b.AssertFileContent("public/libros/fiction/index.html", "List.")
 	b.AssertFileContent("public/libros/fiction/2023/book1/index.html", "Single.")
 }
+
+func TestPermalinksUrlCascade(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- layouts/_default/list.html --
+List|{{ .Kind }}|{{ .RelPermalink }}|
+-- layouts/_default/single.html --
+Single|{{ .Kind }}|{{ .RelPermalink }}|
+-- hugo.toml --
+-- content/cooking/delicious-recipes/_index.md --
+---
+url: /delicious-recipe/
+cascade:
+  url: /delicious-recipe/:slug/
+---
+-- content/cooking/delicious-recipes/example1.md --
+---
+title: Recipe 1
+---
+-- content/cooking/delicious-recipes/example2.md --
+---
+title: Recipe 2
+slug: custom-recipe-2
+---
+`
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			LogLevel:    logg.LevelWarn,
+		}).Build()
+
+	t.Log(b.LogString())
+	b.Assert(b.H.Log.LoggCount(logg.LevelWarn), qt.Equals, 0)
+	b.AssertFileContent("public/delicious-recipe/index.html", "List|section|/delicious-recipe/")
+	b.AssertFileContent("public/delicious-recipe/recipe-1/index.html", "Single|page|/delicious-recipe/recipe-1/")
+	b.AssertFileContent("public/delicious-recipe/custom-recipe-2/index.html", "Single|page|/delicious-recipe/custom-recipe-2/")
+}