diff options
Diffstat (limited to 'helpers/path.go')
-rw-r--r-- | helpers/path.go | 488 |
1 files changed, 488 insertions, 0 deletions
diff --git a/helpers/path.go b/helpers/path.go new file mode 100644 index 000000000..7bc216ec8 --- /dev/null +++ b/helpers/path.go @@ -0,0 +1,488 @@ +// Copyright 2019 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 helpers + +import ( + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/text" + + "github.com/gohugoio/hugo/config" + + "github.com/gohugoio/hugo/hugofs" + + "github.com/gohugoio/hugo/common/hugio" + "github.com/spf13/afero" +) + +// MakePath takes a string with any characters and replace it +// so the string could be used in a path. +// It does so by creating a Unicode-sanitized string, with the spaces replaced, +// whilst preserving the original casing of the string. +// E.g. Social Media -> Social-Media +func (p *PathSpec) MakePath(s string) string { + return p.UnicodeSanitize(s) +} + +// MakePathsSanitized applies MakePathSanitized on every item in the slice +func (p *PathSpec) MakePathsSanitized(paths []string) { + for i, path := range paths { + paths[i] = p.MakePathSanitized(path) + } +} + +// MakePathSanitized creates a Unicode-sanitized string, with the spaces replaced +func (p *PathSpec) MakePathSanitized(s string) string { + if p.DisablePathToLower { + return p.MakePath(s) + } + return strings.ToLower(p.MakePath(s)) +} + +// ToSlashTrimLeading is just a filepath.ToSlaas 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) +} + +// From https://golang.org/src/net/url/url.go +func ishex(c rune) bool { + switch { + case '0' <= c && c <= '9': + return true + case 'a' <= c && c <= 'f': + return true + case 'A' <= c && c <= 'F': + return true + } + return false +} + +// UnicodeSanitize sanitizes string to be used in Hugo URL's, allowing only +// a predefined set of special Unicode characters. +// If RemovePathAccents configuration flag is enabled, Unicode accents +// are also removed. +// Hyphens in the original input are maintained. +// Spaces will be replaced with a single hyphen, and sequential replacement hyphens will be reduced to one. +func (p *PathSpec) UnicodeSanitize(s string) string { + if p.RemovePathAccents { + s = text.RemoveAccentsString(s) + } + + source := []rune(s) + target := make([]rune, 0, len(source)) + var ( + prependHyphen bool + wasHyphen bool + ) + + for i, r := range source { + isAllowed := r == '.' || r == '/' || r == '\\' || r == '_' || r == '#' || r == '+' || r == '~' || r == '-' || r == '@' + isAllowed = isAllowed || unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) + isAllowed = isAllowed || (r == '%' && i+2 < len(source) && ishex(source[i+1]) && ishex(source[i+2])) + + if isAllowed { + // track explicit hyphen in input; no need to add a new hyphen if + // we just saw one. + wasHyphen = r == '-' + + if prependHyphen { + // if currently have a hyphen, don't prepend an extra one + if !wasHyphen { + target = append(target, '-') + } + prependHyphen = false + } + target = append(target, r) + } else if len(target) > 0 && !wasHyphen && unicode.IsSpace(r) { + prependHyphen = true + } + } + + return string(target) +} + +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 = path.Clean(filepath.ToSlash(inPath)) + + if inPath == "." { + return "./" + } + + if !isFileRe.MatchString(inPath) && !strings.HasSuffix(inPath, "/") { + inPath += "/" + } + + if !strings.HasPrefix(inPath, "/") { + inPath = "/" + inPath + } + + dir, _ := path.Split(inPath) + + sectionCount := strings.Count(dir, "/") + + if sectionCount == 0 || dir == "/" { + return "./" + } + + var dottedPath string + + for i := 1; i < sectionCount; i++ { + dottedPath += "../" + } + + return dottedPath +} + +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, ",")) +} + +func ExtractAndGroupRootPaths(paths []string) []NamedSlice { + if len(paths) == 0 { + return nil + } + + pathsCopy := make([]string, len(paths)) + hadSlashPrefix := strings.HasPrefix(paths[0], FilePathSeparator) + + for i, p := range paths { + pathsCopy[i] = strings.Trim(filepath.ToSlash(p), "/") + } + + sort.Strings(pathsCopy) + + pathsParts := make([][]string, len(pathsCopy)) + + for i, p := range pathsCopy { + pathsParts[i] = strings.Split(p, "/") + } + + var groups [][]string + + for i, p1 := range pathsParts { + c1 := -1 + + for j, p2 := range pathsParts { + if i == j { + continue + } + + c2 := -1 + + for i, v := range p1 { + if i >= len(p2) { + break + } + if v != p2[i] { + break + } + + c2 = i + } + + if c1 == -1 || (c2 != -1 && c2 < c1) { + c1 = c2 + } + } + + if c1 != -1 { + groups = append(groups, p1[:c1+1]) + } else { + groups = append(groups, p1) + } + } + + groupsStr := make([]string, len(groups)) + for i, g := range groups { + groupsStr[i] = strings.Join(g, "/") + } + + groupsStr = UniqueStringsSorted(groupsStr) + + var result []NamedSlice + + for _, g := range groupsStr { + name := filepath.FromSlash(g) + if hadSlashPrefix { + name = FilePathSeparator + name + } + ns := NamedSlice{Name: name} + for _, p := range pathsCopy { + if !strings.HasPrefix(p, g) { + continue + } + + p = strings.TrimPrefix(p, g) + if p != "" { + ns.Slice = append(ns.Slice, p) + } + } + + ns.Slice = UniqueStrings(ExtractRootPaths(ns.Slice)) + + result = append(result, ns) + } + + return result +} + +// ExtractRootPaths extracts the root paths from the supplied list of paths. +// The resulting root path will not contain any file separators, but there +// may be duplicates. +// So "/content/section/" becomes "content" +func ExtractRootPaths(paths []string) []string { + r := make([]string, len(paths)) + for i, p := range paths { + root := filepath.ToSlash(p) + sections := strings.Split(root, "/") + for _, section := range sections { + if section != "" { + root = section + break + } + } + r[i] = root + } + return r +} + +// 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 +} + +// SymbolicWalk is like filepath.Walk, but it follows symbolic links. +func SymbolicWalk(fs afero.Fs, root string, walker hugofs.WalkFunc) error { + if _, isOs := fs.(*afero.OsFs); isOs { + // Mainly to track symlinks. + fs = hugofs.NewBaseFileDecorator(fs) + } + + w := hugofs.NewWalkway(hugofs.WalkwayConfig{ + Fs: fs, + Root: root, + WalkFn: walker, + }) + + return w.Walk() +} + +// LstatIfPossible can be used to call Lstat if possible, else Stat. +func LstatIfPossible(fs afero.Fs, path string) (os.FileInfo, error) { + if lstater, ok := fs.(afero.Lstater); ok { + fi, _, err := lstater.LstatIfPossible(path) + return fi, err + } + + return fs.Stat(path) +} + +// SafeWriteToDisk is the same as WriteToDisk +// but it also checks to see if file/directory already exists. +func SafeWriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) { + return afero.SafeWriteReader(fs, inpath, r) +} + +// WriteToDisk writes content to disk. +func WriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) { + return afero.WriteReader(fs, inpath, r) +} + +// OpenFilesForWriting opens all the given filenames for writing. +func OpenFilesForWriting(fs afero.Fs, filenames ...string) (io.WriteCloser, error) { + var writeClosers []io.WriteCloser + for _, filename := range filenames { + f, err := OpenFileForWriting(fs, filename) + if err != nil { + for _, wc := range writeClosers { + wc.Close() + } + return nil, err + } + writeClosers = append(writeClosers, f) + } + + return hugio.NewMultiWriteCloser(writeClosers...), nil +} + +// OpenFileForWriting opens or creates the given file. If the target directory +// does not exist, it gets created. +func OpenFileForWriting(fs afero.Fs, filename string) (afero.File, error) { + filename = filepath.Clean(filename) + // Create will truncate if file already exists. + // os.Create will create any new files with mode 0666 (before umask). + f, err := fs.Create(filename) + if err != nil { + if !herrors.IsNotExist(err) { + return nil, err + } + if err = fs.MkdirAll(filepath.Dir(filename), 0777); err != nil { // before umask + return nil, err + } + f, err = fs.Create(filename) + } + + return f, err +} + +// GetCacheDir returns a cache dir from the given filesystem and config. +// The dir will be created if it does not exist. +func GetCacheDir(fs afero.Fs, cfg config.Provider) (string, error) { + cacheDir := getCacheDir(cfg) + if cacheDir != "" { + exists, err := DirExists(cacheDir, fs) + if err != nil { + return "", err + } + if !exists { + err := fs.MkdirAll(cacheDir, 0777) // Before umask + if err != nil { + return "", fmt.Errorf("failed to create cache dir: %w", err) + } + } + return cacheDir, nil + } + + // Fall back to a cache in /tmp. + return GetTempDir("hugo_cache", fs), nil +} + +func getCacheDir(cfg config.Provider) string { + // Always use the cacheDir config if set. + cacheDir := cfg.GetString("cacheDir") + if len(cacheDir) > 1 { + return addTrailingFileSeparator(cacheDir) + } + + // See Issue #8714. + // Turns out that Cloudflare also sets NETLIFY=true in its build environment, + // but all of these 3 should not give any false positives. + if os.Getenv("NETLIFY") == "true" && os.Getenv("PULL_REQUEST") != "" && os.Getenv("DEPLOY_PRIME_URL") != "" { + // Netlify's cache behaviour is not documented, the currently best example + // is this project: + // https://github.com/philhawksworth/content-shards/blob/master/gulpfile.js + return "/opt/build/cache/hugo_cache/" + } + + // This will fall back to an hugo_cache folder in the tmp dir, which should work fine for most CI + // providers. See this for a working CircleCI setup: + // https://github.com/bep/hugo-sass-test/blob/6c3960a8f4b90e8938228688bc49bdcdd6b2d99e/.circleci/config.yml + // If not, they can set the HUGO_CACHEDIR environment variable or cacheDir config key. + return "" +} + +func addTrailingFileSeparator(s string) string { + if !strings.HasSuffix(s, FilePathSeparator) { + s = s + FilePathSeparator + } + return s +} + +// GetTempDir returns a temporary directory with the given sub path. +func GetTempDir(subPath string, fs afero.Fs) string { + return afero.GetTempDir(fs, subPath) +} + +// DirExists checks if a path exists and is a directory. +func DirExists(path string, fs afero.Fs) (bool, error) { + return afero.DirExists(fs, path) +} + +// IsDir checks if a given path is a directory. +func IsDir(path string, fs afero.Fs) (bool, error) { + return afero.IsDir(fs, path) +} + +// IsEmpty checks if a given path is empty, meaning it doesn't contain any regular files. +func IsEmpty(path string, fs afero.Fs) (bool, error) { + var hasFile bool + err := afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + hasFile = true + return filepath.SkipDir + }) + return !hasFile, err +} + +// Exists checks if a file or directory exists. +func Exists(path string, fs afero.Fs) (bool, error) { + return afero.Exists(fs, path) +} + +// AddTrailingSlash adds a trailing Unix styled slash (/) if not already +// there. +func AddTrailingSlash(path string) string { + if !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} |