From: Joe Mooring Date: Tue, 25 Nov 2025 18:28:35 +0000 (-0800) Subject: tpl/urls: Add PathEscape and PathUnescape functions X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=1a1b062a01e292e79f7fcb7ee476e393cbc270ce;p=brevno-suite%2Fhugo tpl/urls: Add PathEscape and PathUnescape functions Closes #14209 --- diff --git a/tpl/urls/urls.go b/tpl/urls/urls.go index 8bd7514ec..5db6f266d 100644 --- a/tpl/urls/urls.go +++ b/tpl/urls/urls.go @@ -221,3 +221,24 @@ func (ns *Namespace) JoinPath(elements ...any) (string, error) { } return result, nil } + +// PathEscape returns the given string, applying percent-encoding to special +// characters and reserved delimiters so it can be safely used as a segment +// within a URL path. +func (ns *Namespace) PathEscape(s any) (string, error) { + ss, err := cast.ToStringE(s) + if err != nil { + return "", err + } + return url.PathEscape(ss), nil +} + +// PathUnescape returns the given string, replacing all percent-encoded +// sequences with the corresponding unescaped characters. +func (ns *Namespace) PathUnescape(s any) (string, error) { + ss, err := cast.ToStringE(s) + if err != nil { + return "", err + } + return url.PathUnescape(ss) +} diff --git a/tpl/urls/urls_test.go b/tpl/urls/urls_test.go index ae68edcf2..7319a4ea9 100644 --- a/tpl/urls/urls_test.go +++ b/tpl/urls/urls_test.go @@ -15,6 +15,7 @@ package urls import ( "net/url" + "regexp" "testing" "github.com/gohugoio/hugo/config/testconfig" @@ -105,3 +106,82 @@ func TestJoinPath(t *testing.T) { c.Assert(result, qt.Equals, test.expect) } } + +func TestPathEscape(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := newNs() + + tests := []struct { + name string + input any + want string + wantErr bool + errCheck string + }{ + {"string", "A/b/c?d=é&f=g+h", "A%2Fb%2Fc%3Fd=%C3%A9&f=g+h", false, ""}, + {"empty string", "", "", false, ""}, + {"integer", 6, "6", false, ""}, + {"float", 7.42, "7.42", false, ""}, + {"nil", nil, "", false, ""}, + {"slice", []int{}, "", true, "unable to cast"}, + {"map", map[string]string{}, "", true, "unable to cast"}, + {"struct", tstNoStringer{}, "", true, "unable to cast"}, + } + + for _, tt := range tests { + c.Run(tt.name, func(c *qt.C) { + got, err := ns.PathEscape(tt.input) + if tt.wantErr { + c.Assert(err, qt.IsNotNil, qt.Commentf("PathEscape(%v) should have failed", tt.input)) + if tt.errCheck != "" { + c.Assert(err, qt.ErrorMatches, ".*"+regexp.QuoteMeta(tt.errCheck)+".*") + } + } else { + c.Assert(err, qt.IsNil) + c.Assert(got, qt.Equals, tt.want) + } + }) + } +} + +func TestPathUnescape(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := newNs() + + tests := []struct { + name string + input any + want string + wantErr bool + errCheck string + }{ + {"string", "A%2Fb%2Fc%3Fd=%C3%A9&f=g+h", "A/b/c?d=é&f=g+h", false, ""}, + {"empty string", "", "", false, ""}, + {"integer", 6, "6", false, ""}, + {"float", 7.42, "7.42", false, ""}, + {"nil", nil, "", false, ""}, + {"slice", []int{}, "", true, "unable to cast"}, + {"map", map[string]string{}, "", true, "unable to cast"}, + {"struct", tstNoStringer{}, "", true, "unable to cast"}, + {"malformed hex", "bad%g0escape", "", true, "invalid URL escape"}, + {"incomplete hex", "trailing%", "", true, "invalid URL escape"}, + {"single hex digit", "trail%1", "", true, "invalid URL escape"}, + } + + for _, tt := range tests { + c.Run(tt.name, func(c *qt.C) { + got, err := ns.PathUnescape(tt.input) + if tt.wantErr { + c.Assert(err, qt.Not(qt.IsNil), qt.Commentf("PathUnescape(%v) should have failed", tt.input)) + if tt.errCheck != "" { + c.Assert(err, qt.ErrorMatches, ".*"+regexp.QuoteMeta(tt.errCheck)+".*") + } + } else { + c.Assert(err, qt.IsNil) + c.Assert(got, qt.Equals, tt.want) + } + }) + } +}