Repository: gohugoio/hugo (language: go)

Issue:
v0.131.0  Permalinks tokens break previous behavior
Permalinks tokens are extremely useful, and I had a couple of places, they would be beneficial. Tere is currently just one issue. They break most of my page.

```plaintext
Error: error building site: render: error expanding "/wiki/Foo:Bar_Baz": permalink ill-formed
```

Instead of running a full-blown wiki, we went with doing that in hugo. To get roughly the same behavior as Wikipedia would, I used the same URL style. This includes colons in the URL.

Since I couldn't find anything where I could transform the `title` into it, I added `url` to every wiki page like the following.

```yaml
# without whitespace
title: "Help:Infobox"
url: "/wiki/Help:Infobox"

# with whitespace 
title: "Foo:Bar Baz"
url: "/wiki/Foo:Bar_Baz" 

# with whitespace but without colons 
title: "Foo Bar"
url: "/wiki/Foo_Bar" 

# without whitespace but without colons 
title: "Foo"
url: "/wiki/Foo" 
```

Because I was unable to find any workarounds and I consider the wiki URL to be a key feature, I can't upgrade to >= v0.131.0.

Would it be possible to add something like a raw `url` feature to hugo that behaves like `url` did prior to v0.131.0? 
Alternatively, if that use case would be interesting for others, something like a `:wikititle` permalink token, that would replicate the behavior I'm looking for (e.g. do the same as `:title` would, but replace whitespace with underscores and keep the letter case)?

### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.135.0+extended linux/amd64 BuildDate=unknown
</pre>

### Does this issue reproduce with the latest release?
Yes



Discussion / hints:
>Error: error building site: render: error expanding "/wiki/Foo:Bar_Baz": permalink ill-formed

As I see it, there are (at least) 2 issues here:

1. We should not not throw an error when we find tokens we don't know about.
2. But even with that there would be potential ambiguity in your setup (e.g. `/wiki/Foo:slug`)

Hmm ...

I see 3 options ...

1. New `rawurl:`
2. Add a way to escape colons in `url`, e.g. `url: "/wiki/Foo\:Bar_Baz" `
3. Add a new permalinks keyword, e.g. `:wikititle`.

I'm not a fan of 1) and we should probably do 2) even if we also do 3), so I guess we can start with that as a fix + improving the parsing and error message...
LGTM

Fix patch:
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
index 05911f0eaa6..fe9d9f78d8b 100644
--- a/resources/page/permalinks.go
+++ b/resources/page/permalinks.go
@@ -107,27 +107,37 @@ func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]ma
 	return p, nil
 }
 
+// Escape sequence for colons in permalink patterns.
+const escapePlaceholderColon = "\x00"
+
+func (l PermalinkExpander) normalizeEscapeSequencesIn(s string) (string, bool) {
+	s2 := strings.ReplaceAll(s, "\\:", escapePlaceholderColon)
+	return s2, s2 != s
+}
+
+func (l PermalinkExpander) normalizeEscapeSequencesOut(result string) string {
+	return strings.ReplaceAll(result, escapePlaceholderColon, ":")
+}
+
 // 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)
+	expand, err := l.getOrParsePattern(pattern)
 	if err != nil {
 		return "", err
 	}
 
-	return expander(p)
+	return expand(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) {
 	expanders, found := l.expanders[p.Kind()]
-
 	if !found {
 		return "", nil
 	}
 
 	expand, found := expanders[key]
-
 	if !found {
 		return "", nil
 	}
@@ -149,7 +159,19 @@ func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string
 		if !l.validate(pattern) {
 			return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed}
 		}
+		var normalized bool
+		pattern, normalized = l.normalizeEscapeSequencesIn(pattern)
+
 		matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
+		if matches == nil {
+			result := pattern
+			if normalized {
+				result = l.normalizeEscapeSequencesOut(result)
+			}
+			return func(p Page) (string, error) {
+				return result, nil
+			}, nil
+		}
 
 		callbacks := make([]pageToPermaAttribute, len(matches))
 		replacements := make([]string, len(matches))
@@ -167,10 +189,6 @@ func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string
 		}
 
 		return func(p Page) (string, error) {
-			if matches == nil {
-				return pattern, nil
-			}
-
 			newField := pattern
 
 			for i, replacement := range replacements {
@@ -184,6 +202,10 @@ func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string
 				newField = strings.Replace(newField, replacement, newAttr, 1)
 			}
 
+			if normalized {
+				newField = l.normalizeEscapeSequencesOut(newField)
+			}
+
 			return newField, nil
 		}, nil
 	})


Test patch:
diff --git a/resources/page/permalinks_test.go b/resources/page/permalinks_test.go
index a3a45bb8875..808521f42ef 100644
--- a/resources/page/permalinks_test.go
+++ b/resources/page/permalinks_test.go
@@ -44,6 +44,8 @@ var testdataPermalinks = []struct {
 	{"/: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
 
 	// Failures
 	{"/blog/:fred", false, ""},
@@ -90,6 +92,10 @@ func TestPermalinkExpansion(t *testing.T) {
 			expanded, err := expander.Expand("posts", page)
 			c.Assert(err, qt.IsNil)
 			c.Assert(expanded, qt.Equals, item.expandsTo)
+
+			expanded, err = expander.ExpandPattern(item.spec, page)
+			c.Assert(err, qt.IsNil)
+			c.Assert(expanded, qt.Equals, item.expandsTo)
 		})
 
 	}
@@ -117,6 +123,7 @@ func TestPermalinkExpansionMultiSection(t *testing.T) {
 			"posts":   "/:slug",
 			"blog":    "/:section/:year",
 			"recipes": "/:slugorfilename",
+			"special": "/special\\::slug",
 		},
 	}
 	expander, err := NewPermalinkExpander(urlize, permalinksConfig)
@@ -137,6 +144,10 @@ func TestPermalinkExpansionMultiSection(t *testing.T) {
 	expanded, err = expander.Expand("recipes", page_slug_fallback)
 	c.Assert(err, qt.IsNil)
 	c.Assert(expanded, qt.Equals, "/page-filename")
+
+	expanded, err = expander.Expand("special", page)
+	c.Assert(err, qt.IsNil)
+	c.Assert(expanded, qt.Equals, "/special:the-slug")
 }
 
 func TestPermalinkExpansionConcurrent(t *testing.T) {