Repository: gohugoio/hugo (language: go)

Issue:
Add support for Go's net/url JoinPath
<!-- Describe this new feature. Think about if it really belongs in the Hugo core module; you may want to discuss it on https://discourse.gohugo.io/ first.  -->

I ran into an issue trying to use `path.Join` to join a path to a Permalink (I'm new to Hugo and Go): `path.Join` uses `filepath.Join` under the hood, which calls `filepath.Clean`. Among other things, `Clean` will remove any consecutive slashes, clobbering `https://` in `.Permalink`, making it for example `https:/example.com/path/to/a/thing`. Go's `path.Join` also does the same thing with its own implementation of `Clean`.

I dug around and found there's a [very recently added](https://github.com/golang/go/issues/47005) (March 9th) `JoinPath` function in `net/url`. I think it may have missed Go 1.18 by 5 days: it would be a convenient addition to Hugo on the next release of Go.


Discussion / hints:
>path.Join uses filepath.Join under the hood, which calls 

I'm pretty sure the above isn't true, but you are right, we are going to use the new JoinPath, but assuming it's not backported to Go 1.18, I suspect we need to wait until Go 1.20.

>  I think it may have missed Go 1.18 by 5 days

For the record, even though Go 1.18 shipped in March, the feature freeze for it was back in November. It's part of the Go release cycle: https://github.com/golang/go/wiki/Go-Release-Cycle#timeline

I actually did just barely miss the window for adding http.MaxBytesError to 1.18 because I wasn't paying attention to the freeze date.

Fix patch:
diff --git a/docs/content/en/functions/urls.JoinPath.md b/docs/content/en/functions/urls.JoinPath.md
new file mode 100644
index 00000000000..c0e0464b636
--- /dev/null
+++ b/docs/content/en/functions/urls.JoinPath.md
@@ -0,0 +1,24 @@
+---
+title: urls.JoinPath
+description: Joins the provided elements into a URL string and cleans the result of any ./ or ../ elements.
+categories: [functions]
+menu:
+  docs:
+    parent: functions
+keywords: [urls,path,join]
+signature: ["urls.JoinPath ELEMENT..."]
+---
+
+```go-html-template
+{{ urls.JoinPath "" }} → "/"
+{{ urls.JoinPath "a" }} → "a"
+{{ urls.JoinPath "a" "b" }} → "a/b"
+{{ urls.JoinPath "/a" "b" }} → "/a/b"
+{{ urls.JoinPath "https://example.org" "b" }} → "https://example.org/b"
+
+{{ urls.JoinPath (slice "a" "b") }} → "a/b"
+```
+
+Unlike the [`path.Join`] function, `urls.JoinPath` retains consecutive leading slashes.
+
+[`path.Join`]: /functions/path.join/
diff --git a/tpl/urls/init.go b/tpl/urls/init.go
index ec954640d29..cdc597f3444 100644
--- a/tpl/urls/init.go
+++ b/tpl/urls/init.go
@@ -68,6 +68,14 @@ func init() {
 			},
 		)
 
+		ns.AddMethodMapping(ctx.JoinPath,
+			nil,
+			[][2]string{
+				{`{{ urls.JoinPath "https://example.org" "foo" }}`, `https://example.org/foo`},
+				{`{{ urls.JoinPath (slice "a" "b") }}`, `a/b`},
+			},
+		)
+
 		return ns
 	}
 
diff --git a/tpl/urls/urls.go b/tpl/urls/urls.go
index bfbd7304ffb..de0f1ac10eb 100644
--- a/tpl/urls/urls.go
+++ b/tpl/urls/urls.go
@@ -185,3 +185,38 @@ func (ns *Namespace) AbsLangURL(s any) (template.HTML, error) {
 
 	return template.HTML(ns.deps.PathSpec.AbsURL(ss, !ns.multihost)), nil
 }
+
+// JoinPath joins the provided elements into a URL string and cleans the result
+// of any ./ or ../ elements.
+func (ns *Namespace) JoinPath(elements ...any) (string, error) {
+
+	var selements []string
+	for _, e := range elements {
+		switch v := e.(type) {
+		case []string:
+			for _, e := range v {
+				selements = append(selements, e)
+			}
+		case []any:
+			for _, e := range v {
+				se, err := cast.ToStringE(e)
+				if err != nil {
+					return "", err
+				}
+				selements = append(selements, se)
+			}
+		default:
+			se, err := cast.ToStringE(e)
+			if err != nil {
+				return "", err
+			}
+			selements = append(selements, se)
+		}
+	}
+
+	result, err := url.JoinPath(selements[0], selements[1:]...)
+	if err != nil {
+		return "", err
+	}
+	return result, nil
+}


Test patch:
diff --git a/tpl/urls/urls_test.go b/tpl/urls/urls_test.go
index 73b5cd14122..0ea91fc952a 100644
--- a/tpl/urls/urls_test.go
+++ b/tpl/urls/urls_test.go
@@ -68,3 +68,38 @@ func TestParse(t *testing.T) {
 			qt.CmpEquals(hqt.DeepAllowUnexported(&url.URL{}, url.Userinfo{})), test.expect)
 	}
 }
+
+func TestJoinPath(t *testing.T) {
+	t.Parallel()
+	c := qt.New(t)
+
+	for _, test := range []struct {
+		elements any
+		expect   any
+	}{
+		{"", `/`},
+		{"a", `a`},
+		{"/a/b", `/a/b`},
+		{"./../a/b", `a/b`},
+		{[]any{""}, `/`},
+		{[]any{"a"}, `a`},
+		{[]any{"/a", "b"}, `/a/b`},
+		{[]any{".", "..", "/a", "b"}, `a/b`},
+		{[]any{"https://example.org", "a"}, `https://example.org/a`},
+		{[]any{nil}, `/`},
+		// errors
+		{tstNoStringer{}, false},
+		{[]any{tstNoStringer{}}, false},
+	} {
+
+		result, err := ns.JoinPath(test.elements)
+
+		if b, ok := test.expect.(bool); ok && !b {
+			c.Assert(err, qt.Not(qt.IsNil))
+			continue
+		}
+
+		c.Assert(err, qt.IsNil)
+		c.Assert(result, qt.Equals, test.expect)
+	}
+}