diff options
author | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-06-18 10:27:27 +0200 |
---|---|---|
committer | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-06-18 10:55:00 +0200 |
commit | 93aad3c543828efca2adeb7f96cf50ae29878593 (patch) | |
tree | 6a3dd4f6f878f8f36be9cef03a15b78b19d1f3da | |
parent | 5af045ebab109d3e5501b8b6d9fd448840c96c9a (diff) |
Split out the puthe path/filepath functions into common/paths
So they can be used from the config package without cyclic troubles.
Updates #8654
-rw-r--r-- | commands/server.go | 4 | ||||
-rw-r--r-- | common/paths/path.go | 312 | ||||
-rw-r--r-- | common/paths/path_test.go | 252 | ||||
-rw-r--r-- | common/paths/url.go | 212 | ||||
-rw-r--r-- | common/paths/url_test.go | 129 | ||||
-rw-r--r-- | create/content.go | 4 | ||||
-rw-r--r-- | create/content_template_handler.go | 4 | ||||
-rw-r--r-- | helpers/path.go | 185 | ||||
-rw-r--r-- | helpers/path_test.go | 135 | ||||
-rw-r--r-- | helpers/url.go | 153 | ||||
-rw-r--r-- | helpers/url_test.go | 108 | ||||
-rw-r--r-- | hugolib/config.go | 7 | ||||
-rw-r--r-- | hugolib/content_map_test.go | 4 | ||||
-rw-r--r-- | hugolib/pagecollections.go | 4 | ||||
-rw-r--r-- | hugolib/site.go | 4 | ||||
-rw-r--r-- | langs/i18n/translationProvider.go | 6 | ||||
-rw-r--r-- | resources/image.go | 6 | ||||
-rw-r--r-- | resources/image_test.go | 4 | ||||
-rw-r--r-- | resources/page/pagemeta/page_frontmatter.go | 4 | ||||
-rw-r--r-- | resources/transform.go | 6 | ||||
-rw-r--r-- | source/fileInfo.go | 4 |
21 files changed, 956 insertions, 591 deletions
diff --git a/commands/server.go b/commands/server.go index 5cb43470b..02db354ba 100644 --- a/commands/server.go +++ b/commands/server.go @@ -31,6 +31,8 @@ import ( "syscall" "time" + "github.com/gohugoio/hugo/common/paths" + "github.com/pkg/errors" "github.com/gohugoio/hugo/livereload" @@ -275,7 +277,7 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error { func getRootWatchDirsStr(baseDir string, watchDirs []string) string { relWatchDirs := make([]string, len(watchDirs)) for i, dir := range watchDirs { - relWatchDirs[i], _ = helpers.GetRelativePath(dir, baseDir) + relWatchDirs[i], _ = paths.GetRelativePath(dir, baseDir) } return strings.Join(helpers.UniqueStringsSorted(helpers.ExtractRootPaths(relWatchDirs)), ",") diff --git a/common/paths/path.go b/common/paths/path.go new file mode 100644 index 000000000..0237dd9f2 --- /dev/null +++ b/common/paths/path.go @@ -0,0 +1,312 @@ +// Copyright 2021 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package paths + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +// FilePathSeparator as defined by os.Separator. +const FilePathSeparator = string(filepath.Separator) + +// filepathPathBridge is a bridge for common functionality in filepath vs path +type filepathPathBridge interface { + Base(in string) string + Clean(in string) string + Dir(in string) string + Ext(in string) string + Join(elem ...string) string + Separator() string +} + +type filepathBridge struct { +} + +func (filepathBridge) Base(in string) string { + return filepath.Base(in) +} + +func (filepathBridge) Clean(in string) string { + return filepath.Clean(in) +} + +func (filepathBridge) Dir(in string) string { + return filepath.Dir(in) +} + +func (filepathBridge) Ext(in string) string { + return filepath.Ext(in) +} + +func (filepathBridge) Join(elem ...string) string { + return filepath.Join(elem...) +} + +func (filepathBridge) Separator() string { + return FilePathSeparator +} + +var fpb filepathBridge + +// ToSlashTrimLeading is just a filepath.ToSlash with an added / prefix trimmer. +func ToSlashTrimLeading(s string) string { + return strings.TrimPrefix(filepath.ToSlash(s), "/") +} + +// MakeTitle converts the path given to a suitable title, trimming whitespace +// and replacing hyphens with whitespace. +func MakeTitle(inpath string) string { + return strings.Replace(strings.TrimSpace(inpath), "-", " ", -1) +} + +// ReplaceExtension takes a path and an extension, strips the old extension +// and returns the path with the new extension. +func ReplaceExtension(path string, newExt string) string { + f, _ := fileAndExt(path, fpb) + return f + "." + newExt +} + +func makePathRelative(inPath string, possibleDirectories ...string) (string, error) { + for _, currentPath := range possibleDirectories { + if strings.HasPrefix(inPath, currentPath) { + return strings.TrimPrefix(inPath, currentPath), nil + } + } + return inPath, errors.New("can't extract relative path, unknown prefix") +} + +// Should be good enough for Hugo. +var isFileRe = regexp.MustCompile(`.*\..{1,6}$`) + +// GetDottedRelativePath expects a relative path starting after the content directory. +// It returns a relative path with dots ("..") navigating up the path structure. +func GetDottedRelativePath(inPath string) string { + inPath = filepath.Clean(filepath.FromSlash(inPath)) + + if inPath == "." { + return "./" + } + + if !isFileRe.MatchString(inPath) && !strings.HasSuffix(inPath, FilePathSeparator) { + inPath += FilePathSeparator + } + + if !strings.HasPrefix(inPath, FilePathSeparator) { + inPath = FilePathSeparator + inPath + } + + dir, _ := filepath.Split(inPath) + + sectionCount := strings.Count(dir, FilePathSeparator) + + if sectionCount == 0 || dir == FilePathSeparator { + return "./" + } + + var dottedPath string + + for i := 1; i < sectionCount; i++ { + dottedPath += "../" + } + + return dottedPath +} + +// ExtNoDelimiter takes a path and returns the extension, excluding the delimiter, i.e. "md". +func ExtNoDelimiter(in string) string { + return strings.TrimPrefix(Ext(in), ".") +} + +// Ext takes a path and returns the extension, including the delimiter, i.e. ".md". +func Ext(in string) string { + _, ext := fileAndExt(in, fpb) + return ext +} + +// PathAndExt is the same as FileAndExt, but it uses the path package. +func PathAndExt(in string) (string, string) { + return fileAndExt(in, pb) +} + +// FileAndExt takes a path and returns the file and extension separated, +// the extension including the delimiter, i.e. ".md". +func FileAndExt(in string) (string, string) { + return fileAndExt(in, fpb) +} + +// FileAndExtNoDelimiter takes a path and returns the file and extension separated, +// the extension excluding the delimiter, e.g "md". +func FileAndExtNoDelimiter(in string) (string, string) { + file, ext := fileAndExt(in, fpb) + return file, strings.TrimPrefix(ext, ".") +} + +// Filename takes a file path, strips out the extension, +// and returns the name of the file. +func Filename(in string) (name string) { + name, _ = fileAndExt(in, fpb) + return +} + +// PathNoExt takes a path, strips out the extension, +// and returns the name of the file. +func PathNoExt(in string) string { + return strings.TrimSuffix(in, path.Ext(in)) +} + +// FileAndExt returns the filename and any extension of a file path as +// two separate strings. +// +// If the path, in, contains a directory name ending in a slash, +// then both name and ext will be empty strings. +// +// If the path, in, is either the current directory, the parent +// directory or the root directory, or an empty string, +// then both name and ext will be empty strings. +// +// If the path, in, represents the path of a file without an extension, +// then name will be the name of the file and ext will be an empty string. +// +// If the path, in, represents a filename with an extension, +// then name will be the filename minus any extension - including the dot +// and ext will contain the extension - minus the dot. +func fileAndExt(in string, b filepathPathBridge) (name string, ext string) { + ext = b.Ext(in) + base := b.Base(in) + + return extractFilename(in, ext, base, b.Separator()), ext +} + +func extractFilename(in, ext, base, pathSeparator string) (name string) { + // No file name cases. These are defined as: + // 1. any "in" path that ends in a pathSeparator + // 2. any "base" consisting of just an pathSeparator + // 3. any "base" consisting of just an empty string + // 4. any "base" consisting of just the current directory i.e. "." + // 5. any "base" consisting of just the parent directory i.e. ".." + if (strings.LastIndex(in, pathSeparator) == len(in)-1) || base == "" || base == "." || base == ".." || base == pathSeparator { + name = "" // there is NO filename + } else if ext != "" { // there was an Extension + // return the filename minus the extension (and the ".") + name = base[:strings.LastIndex(base, ".")] + } else { + // no extension case so just return base, which willi + // be the filename + name = base + } + return +} + +// GetRelativePath returns the relative path of a given path. +func GetRelativePath(path, base string) (final string, err error) { + if filepath.IsAbs(path) && base == "" { + return "", errors.New("source: missing base directory") + } + name := filepath.Clean(path) + base = filepath.Clean(base) + + name, err = filepath.Rel(base, name) + if err != nil { + return "", err + } + + if strings.HasSuffix(filepath.FromSlash(path), FilePathSeparator) && !strings.HasSuffix(name, FilePathSeparator) { + name += FilePathSeparator + } + return name, nil +} + +// PathPrep prepares the path using the uglify setting to create paths on +// either the form /section/name/index.html or /section/name.html. +func PathPrep(ugly bool, in string) string { + if ugly { + return Uglify(in) + } + return PrettifyPath(in) +} + +// PrettifyPath is the same as PrettifyURLPath but for file paths. +// /section/name.html becomes /section/name/index.html +// /section/name/ becomes /section/name/index.html +// /section/name/index.html becomes /section/name/index.html +func PrettifyPath(in string) string { + return prettifyPath(in, fpb) +} + +func prettifyPath(in string, b filepathPathBridge) string { + if filepath.Ext(in) == "" { + // /section/name/ -> /section/name/index.html + if len(in) < 2 { + return b.Separator() + } + return b.Join(in, "index.html") + } + name, ext := fileAndExt(in, b) + if name == "index" { + // /section/name/index.html -> /section/name/index.html + return b.Clean(in) + } + // /section/name.html -> /section/name/index.html + return b.Join(b.Dir(in), name, "index"+ext) +} + +type NamedSlice struct { + Name string + Slice []string +} + +func (n NamedSlice) String() string { + if len(n.Slice) == 0 { + return n.Name + } + return fmt.Sprintf("%s%s{%s}", n.Name, FilePathSeparator, strings.Join(n.Slice, ",")) +} + +// FindCWD returns the current working directory from where the Hugo +// executable is run. +func FindCWD() (string, error) { + serverFile, err := filepath.Abs(os.Args[0]) + if err != nil { + return "", fmt.Errorf("can't get absolute path for executable: %v", err) + } + + path := filepath.Dir(serverFile) + realFile, err := filepath.EvalSymlinks(serverFile) + if err != nil { + if _, err = os.Stat(serverFile + ".exe"); err == nil { + realFile = filepath.Clean(serverFile + ".exe") + } + } + + if err == nil && realFile != serverFile { + path = filepath.Dir(realFile) + } + + return path, nil +} + +// AddTrailingSlash adds a trailing Unix styled slash (/) if not already +// there. +func AddTrailingSlash(path string) string { + if !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} diff --git a/common/paths/path_test.go b/common/paths/path_test.go new file mode 100644 index 000000000..e55493c7d --- /dev/null +++ b/common/paths/path_test.go @@ -0,0 +1,252 @@ +// Copyright 2021 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package paths + +import ( + "path/filepath" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestGetRelativePath(t *testing.T) { + tests := []struct { + path string + base string + expect interface{} + }{ + {filepath.FromSlash("/a/b"), filepath.FromSlash("/a"), filepath.FromSlash("b")}, + {filepath.FromSlash("/a/b/c/"), filepath.FromSlash("/a"), filepath.FromSlash("b/c/")}, + {filepath.FromSlash("/c"), filepath.FromSlash("/a/b"), filepath.FromSlash("../../c")}, + {filepath.FromSlash("/c"), "", false}, + } + for i, this := range tests { + // ultimately a fancy wrapper around filepath.Rel + result, err := GetRelativePath(this.path, this.base) + + if b, ok := this.expect.(bool); ok && !b { + if err == nil { + t.Errorf("[%d] GetRelativePath didn't return an expected error", i) + } + } else { + if err != nil { + t.Errorf("[%d] GetRelativePath failed: %s", i, err) + continue + } + if result != this.expect { + t.Errorf("[%d] GetRelativePath got %v but expected %v", i, result, this.expect) + } + } + + } +} + +func TestMakePathRelative(t *testing.T) { + type test struct { + inPath, path1, path2, output string + } + + data := []test{ + {"/abc/bcd/ab.css", "/abc/bcd", "/bbc/bcd", "/ab.css"}, + {"/abc/bcd/ab.css", "/abcd/bcd", "/abc/bcd", "/ab.css"}, + } + + for i, d := range data { + output, _ := makePathRelative(d.inPath, d.path1, d.path2) + if d.output != output { + t.Errorf("Test #%d failed. Expected %q got %q", i, d.output, output) + } + } + _, error := makePathRelative("a/b/c.ss", "/a/c", "/d/c", "/e/f") + + if error == nil { + t.Errorf("Test failed, expected error") + } +} + +func TestGetDottedRelativePath(t *testing.T) { + // on Windows this will receive both kinds, both country and western ... + for _, f := range []func(string) string{filepath.FromSlash, func(s string) string { return s }} { + doTestGetDottedRelativePath(f, t) + } +} + +func doTestGetDottedRelativePath(urlFixer func(string) string, t *testing.T) { + type test struct { + input, expected string + } + data := []test{ + {"", "./"}, + {urlFixer("/"), "./"}, + {urlFixer("post"), "../"}, + {urlFixer("/post"), "../"}, + {urlFixer("post/"), "../"}, + {urlFixer("tags/foo.html"), "../"}, + {urlFixer("/tags/foo.html"), "../"}, + {urlFixer("/post/"), "../"}, + {urlFixer("////post/////"), "../"}, + {urlFixer("/foo/bar/index.html"), "../../"}, + {urlFixer("/foo/bar/foo/"), "../../../"}, + {urlFixer("/foo/bar/foo"), "../../../"}, + {urlFixer("foo/bar/foo/"), "../../../"}, + {urlFixer("foo/bar/foo/bar"), "../../../../"}, + {"404.html", "./"}, + {"404.xml", "./"}, + {"/404.html", "./"}, + } + for i, d := range data { + output := GetDottedRelativePath(d.input) + if d.expected != output { + t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) + } + } +} + +func TestMakeTitle(t *testing.T) { + type test struct { + input, expected string + } + data := []test{ + {"Make-Title", "Make Title"}, + {"MakeTitle", "MakeTitle"}, + {"make_title", "make_title"}, + } + for i, d := range data { + output := MakeTitle(d.input) + if d.expected != output { + t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) + } + } +} + +// Replace Extension is probably poorly named, but the intent of the +// function is to accept a path and return only the file name with a +// new extension. It's intentionally designed to strip out the path +// and only provide the name. We should probably rename the function to +// be more explicit at some point. +func TestReplaceExtension(t *testing.T) { + type test struct { + input, newext, expected string + } + data := []test{ + // These work according to the above definition + {"/some/random/path/file.xml", "html", "file.html"}, + {"/banana.html", "xml", "banana.xml"}, + {"./banana.html", "xml", "banana.xml"}, + {"banana/pie/index.html", "xml", "index.xml"}, + {"../pies/fish/index.html", "xml", "index.xml"}, + // but these all fail + {"filename-without-an-ext", "ext", "filename-without-an-ext.ext"}, + {"/filename-without-an-ext", "ext", "filename-without-an-ext.ext"}, + {"/directory/mydir/", "ext", ".ext"}, + {"mydir/", "ext", ".ext"}, + } + + for i, d := range data { + output := ReplaceExtension(filepath.FromSlash(d.input), d.newext) + if d.expected != output { + t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) + } + } +} + +func TestExtNoDelimiter(t *testing.T) { + c := qt.New(t) + c.Assert(ExtNoDelimiter(filepath.FromSlash("/my/data.json")), qt.Equals, "json") +} + +func TestFilename(t *testing.T) { + type test struct { + input, expected string + } + data := []test{ + {"index.html", "index"}, + {"./index.html", "index"}, + {"/index.html", "index"}, + {"index", "index"}, + {"/tmp/index.html", "index"}, + {"./filename-no-ext", "filename-no-ext"}, + {"/filename-no-ext", "filename-no-ext"}, + {"filename-no-ext", "filename-no-ext"}, + {"directory/", ""}, // no filename case?? + {"directory/.hidden.ext", ".hidden"}, + {"./directory/../~/banana/gold.fish", "gold"}, + {"../directory/banana.man", "banana"}, + {"~/mydir/filename.ext", "filename"}, + {"./directory//tmp/filename.ext", "filename"}, + } + + for i, d := range data { + output := Filename(filepath.FromSlash(d.input)) + if d.expected != output { + t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) + } + } +} + +func TestFileAndExt(t *testing.T) { + type test struct { + input, expectedFile, expectedExt string + } + data := []test{ + {"index.html", "index", ".html"}, + {"./index.html", "index", ".html"}, + {"/index.html", "index", ".html"}, + {"index", "index", ""}, + {"/tmp/index.html", "index", ".html"}, + {"./filename-no-ext", "filename-no-ext", ""}, + {"/filename-no-ext", "filename-no-ext", ""}, + {"filename-no-ext", "filename-no-ext", ""}, + {"directory/", "", ""}, // no filename case?? + {"directory/.hidden.ext", ".hidden", ".ext"}, + {"./directory/../~/banana/gold.fish", "gold", ".fish"}, + {"../directory/banana.man", "banana", ".man"}, + {"~/mydir/filename.ext", "filename", ".ext"}, + {"./directory//tmp/filename.ext", "filename", ".ext"}, + } + + for i, d := range data { + file, ext := fileAndExt(filepath.FromSlash(d.input), fpb) + if d.expectedFile != file { + t.Errorf("Test %d failed. Expected filename %q got %q.", i, d.expectedFile, file) + } + if d.expectedExt != ext { + t.Errorf("Test %d failed. Expected extension %q got %q.", i, d.expectedExt, ext) + } + } +} + +func TestFindCWD(t *testing.T) { + type test struct { + expectedDir string + expectedErr error + } + + // cwd, _ := os.Getwd() + data := []test{ + //{cwd, nil}, + // Commenting this out. It doesn't work properly. + // There's a good reason why we don't use os.Getwd(), it doesn't actually work the way we want it to. + // I really don't know a better way to test this function. - SPF 2014.11.04 + } + for i, d := range data { + dir, err := FindCWD() + if d.expectedDir != dir { + t.Errorf("Test %d failed. Expected %q but got %q", i, d.expectedDir, dir) + } + if d.expectedErr != err { + t.Errorf("Test %d failed. Expected %q but got %q", i, d.expectedErr, err) + } + } +} diff --git a/common/paths/url.go b/common/paths/url.go new file mode 100644 index 000000000..600e8d22d --- /dev/null +++ b/common/paths/url.go @@ -0,0 +1,212 @@ +// Copyright 2021 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package paths + +import ( + "fmt" + "net/url" + "path" + "strings" + + "github.com/PuerkitoBio/purell" +) + +type pathBridge struct { +} + +func (pathBridge) Base(in string) string { + return path.Base(in) +} + +func (pathBridge) Clean(in string) string { + return path.Clean(in) +} + +func (pathBridge) Dir(in string) string { + return path.Dir(in) +} + +func (pathBridge) Ext(in string) string { + return path.Ext(in) +} + +func (pathBridge) Join(elem ...string) string { + return path.Join(elem...) +} + +func (pathBridge) Separator() string { + return "/" +} + +var pb pathBridge + +func sanitizeURLWithFlags(in string, f purell.NormalizationFlags) string { + s, err := purell.NormalizeURLString(in, f) + if err != nil { + return in + } + + // Temporary workaround for the bug fix and resulting + // behavioral change in purell.NormalizeURLString(): + // a leading '/' was inadvertently added to relative links, + // but no longer, see #878. + // + // I think the real solution is to allow Hugo to + // make relative URL with relative path, + // e.g. "../../post/hello-again/", as wished by users + // in issues #157, #622, etc., without forcing + // relative URLs to begin with '/'. + // Once the fixes are in, let's remove this kludge + // and restore SanitizeURL() to the way it was. + // -- @anthonyfok, 2015-02-16 + // + // Begin temporary kludge + u, err := url.Parse(s) + if err != nil { + panic(err) + } + if len(u.Path) > 0 && !strings.HasPrefix(u.Path, "/") { + u.Path = "/" + u.Path + } + return u.String() + // End temporary kludge + + // return s + +} + +// SanitizeURL sanitizes the input URL string. +func SanitizeURL(in string) string { + return sanitizeURLWithFlags(in, purell.FlagsSafe|purell.FlagRemoveTrailingSlash|purell.FlagRemoveDotSegments|purell.FlagRemoveDuplicateSlashes|purell.FlagRemoveUnnecessaryHostDots|purell.FlagRemoveEmptyPortSeparator) +} + +// SanitizeURLKeepTrailingSlash is the same as SanitizeURL, but will keep any trailing slash. +func SanitizeURLKeepTrailingSlash(in string) string { + return sanitizeURLWithFlags(in, purell.FlagsSafe|purell.FlagRemoveDotSegments|purell.FlagRemoveDuplicateSlashes|purell.FlagRemoveUnnecessaryHostDots|purell.FlagRemoveEmptyPortSeparator) +} + +// MakePermalink combines base URL with content path to create full URL paths. +// Example +// base: http://spf13.com/ +// path: post/how-i-blog +// result: http://spf13.com/post/how-i-blog +func MakePermalink(host, plink string) *url.URL { + base, err := url.Parse(host) + if err != nil { + panic(err) + } + + p, err := url.Parse(plink) + if err != nil { + panic(err) + } + + if p.Host != "" { + panic(fmt.Errorf("can't make permalink from absolute link %q", plink)) + } + + base.Path = path.Join(base.Path, p.Path) + + // path.Join will strip off the last /, so put it back if it was there. + hadTrailingSlash := (plink == "" && strings.HasSuffix(host, "/")) || strings.HasSuffix(p.Path, "/") + if hadTrailingSlash && !strings.HasSuffix(base.Path, "/") { + base.Path = base.Path + "/" + } + + return base +} + +// IsAbsURL determines whether the given path points to an absolute URL. +func IsAbsURL(path string) bool { + url, err := url.Parse(path) + if err != nil { + return false + } + + return url.IsAbs() || strings.HasPrefix(path, "//") +} + +// AddContextRoot adds the context root to an URL if it's not already set. +// For relative URL entries on sites with a base url with a context root set (i.e. http://example.com/mysite), +// relative URLs must not include the context root if canonifyURLs is enabled. But if it's disabled, it must be set. +func AddContextRoot(baseURL, relativePa |