github.com/jbramsden/hugo@v0.47.1/helpers/path.go (about)

     1  // Copyright 2015 The Hugo Authors. All rights reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  package helpers
    15  
    16  import (
    17  	"errors"
    18  	"fmt"
    19  	"io"
    20  	"os"
    21  	"path/filepath"
    22  	"regexp"
    23  	"sort"
    24  	"strings"
    25  	"unicode"
    26  
    27  	"github.com/gohugoio/hugo/common/hugio"
    28  	"github.com/spf13/afero"
    29  	"golang.org/x/text/transform"
    30  	"golang.org/x/text/unicode/norm"
    31  )
    32  
    33  var (
    34  	// ErrThemeUndefined is returned when a theme has not be defined by the user.
    35  	ErrThemeUndefined = errors.New("no theme set")
    36  )
    37  
    38  // filepathPathBridge is a bridge for common functionality in filepath vs path
    39  type filepathPathBridge interface {
    40  	Base(in string) string
    41  	Clean(in string) string
    42  	Dir(in string) string
    43  	Ext(in string) string
    44  	Join(elem ...string) string
    45  	Separator() string
    46  }
    47  
    48  type filepathBridge struct {
    49  }
    50  
    51  func (filepathBridge) Base(in string) string {
    52  	return filepath.Base(in)
    53  }
    54  
    55  func (filepathBridge) Clean(in string) string {
    56  	return filepath.Clean(in)
    57  }
    58  
    59  func (filepathBridge) Dir(in string) string {
    60  	return filepath.Dir(in)
    61  }
    62  
    63  func (filepathBridge) Ext(in string) string {
    64  	return filepath.Ext(in)
    65  }
    66  
    67  func (filepathBridge) Join(elem ...string) string {
    68  	return filepath.Join(elem...)
    69  }
    70  
    71  func (filepathBridge) Separator() string {
    72  	return FilePathSeparator
    73  }
    74  
    75  var fpb filepathBridge
    76  
    77  // MakePath takes a string with any characters and replace it
    78  // so the string could be used in a path.
    79  // It does so by creating a Unicode-sanitized string, with the spaces replaced,
    80  // whilst preserving the original casing of the string.
    81  // E.g. Social Media -> Social-Media
    82  func (p *PathSpec) MakePath(s string) string {
    83  	return p.UnicodeSanitize(strings.Replace(strings.TrimSpace(s), " ", "-", -1))
    84  }
    85  
    86  // MakePathSanitized creates a Unicode-sanitized string, with the spaces replaced
    87  func (p *PathSpec) MakePathSanitized(s string) string {
    88  	if p.DisablePathToLower {
    89  		return p.MakePath(s)
    90  	}
    91  	return strings.ToLower(p.MakePath(s))
    92  }
    93  
    94  // ToSlashTrimLeading is just a filepath.ToSlaas with an added / prefix trimmer.
    95  func ToSlashTrimLeading(s string) string {
    96  	return strings.TrimPrefix(filepath.ToSlash(s), "/")
    97  }
    98  
    99  // MakeTitle converts the path given to a suitable title, trimming whitespace
   100  // and replacing hyphens with whitespace.
   101  func MakeTitle(inpath string) string {
   102  	return strings.Replace(strings.TrimSpace(inpath), "-", " ", -1)
   103  }
   104  
   105  // From https://golang.org/src/net/url/url.go
   106  func ishex(c rune) bool {
   107  	switch {
   108  	case '0' <= c && c <= '9':
   109  		return true
   110  	case 'a' <= c && c <= 'f':
   111  		return true
   112  	case 'A' <= c && c <= 'F':
   113  		return true
   114  	}
   115  	return false
   116  }
   117  
   118  // UnicodeSanitize sanitizes string to be used in Hugo URL's, allowing only
   119  // a predefined set of special Unicode characters.
   120  // If RemovePathAccents configuration flag is enabled, Uniccode accents
   121  // are also removed.
   122  func (p *PathSpec) UnicodeSanitize(s string) string {
   123  	source := []rune(s)
   124  	target := make([]rune, 0, len(source))
   125  
   126  	for i, r := range source {
   127  		if r == '%' && i+2 < len(source) && ishex(source[i+1]) && ishex(source[i+2]) {
   128  			target = append(target, r)
   129  		} else if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) || r == '.' || r == '/' || r == '\\' || r == '_' || r == '-' || r == '#' || r == '+' || r == '~' {
   130  			target = append(target, r)
   131  		}
   132  	}
   133  
   134  	var result string
   135  
   136  	if p.RemovePathAccents {
   137  		// remove accents - see https://blog.golang.org/normalization
   138  		t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC)
   139  		result, _, _ = transform.String(t, string(target))
   140  	} else {
   141  		result = string(target)
   142  	}
   143  
   144  	return result
   145  }
   146  
   147  func isMn(r rune) bool {
   148  	return unicode.Is(unicode.Mn, r) // Mn: nonspacing marks
   149  }
   150  
   151  // ReplaceExtension takes a path and an extension, strips the old extension
   152  // and returns the path with the new extension.
   153  func ReplaceExtension(path string, newExt string) string {
   154  	f, _ := fileAndExt(path, fpb)
   155  	return f + "." + newExt
   156  }
   157  
   158  // GetFirstThemeDir gets the root directory of the first theme, if there is one.
   159  // If there is no theme, returns the empty string.
   160  func (p *PathSpec) GetFirstThemeDir() string {
   161  	if p.ThemeSet() {
   162  		return p.AbsPathify(filepath.Join(p.ThemesDir, p.Themes()[0]))
   163  	}
   164  	return ""
   165  }
   166  
   167  // GetThemesDir gets the absolute root theme dir path.
   168  func (p *PathSpec) GetThemesDir() string {
   169  	if p.ThemeSet() {
   170  		return p.AbsPathify(p.ThemesDir)
   171  	}
   172  	return ""
   173  }
   174  
   175  // GetRelativeThemeDir gets the relative root directory of the current theme, if there is one.
   176  // If there is no theme, returns the empty string.
   177  func (p *PathSpec) GetRelativeThemeDir() string {
   178  	if p.ThemeSet() {
   179  		return strings.TrimPrefix(filepath.Join(p.ThemesDir, p.Themes()[0]), FilePathSeparator)
   180  	}
   181  	return ""
   182  }
   183  
   184  func makePathRelative(inPath string, possibleDirectories ...string) (string, error) {
   185  
   186  	for _, currentPath := range possibleDirectories {
   187  		if strings.HasPrefix(inPath, currentPath) {
   188  			return strings.TrimPrefix(inPath, currentPath), nil
   189  		}
   190  	}
   191  	return inPath, errors.New("Can't extract relative path, unknown prefix")
   192  }
   193  
   194  // Should be good enough for Hugo.
   195  var isFileRe = regexp.MustCompile(`.*\..{1,6}$`)
   196  
   197  // GetDottedRelativePath expects a relative path starting after the content directory.
   198  // It returns a relative path with dots ("..") navigating up the path structure.
   199  func GetDottedRelativePath(inPath string) string {
   200  	inPath = filepath.Clean(filepath.FromSlash(inPath))
   201  
   202  	if inPath == "." {
   203  		return "./"
   204  	}
   205  
   206  	if !isFileRe.MatchString(inPath) && !strings.HasSuffix(inPath, FilePathSeparator) {
   207  		inPath += FilePathSeparator
   208  	}
   209  
   210  	if !strings.HasPrefix(inPath, FilePathSeparator) {
   211  		inPath = FilePathSeparator + inPath
   212  	}
   213  
   214  	dir, _ := filepath.Split(inPath)
   215  
   216  	sectionCount := strings.Count(dir, FilePathSeparator)
   217  
   218  	if sectionCount == 0 || dir == FilePathSeparator {
   219  		return "./"
   220  	}
   221  
   222  	var dottedPath string
   223  
   224  	for i := 1; i < sectionCount; i++ {
   225  		dottedPath += "../"
   226  	}
   227  
   228  	return dottedPath
   229  }
   230  
   231  // ExtNoDelimiter takes a path and returns the extension, excluding the delmiter, i.e. "md".
   232  func ExtNoDelimiter(in string) string {
   233  	return strings.TrimPrefix(Ext(in), ".")
   234  }
   235  
   236  // Ext takes a path and returns the extension, including the delmiter, i.e. ".md".
   237  func Ext(in string) string {
   238  	_, ext := fileAndExt(in, fpb)
   239  	return ext
   240  }
   241  
   242  // PathAndExt is the same as FileAndExt, but it uses the path package.
   243  func PathAndExt(in string) (string, string) {
   244  	return fileAndExt(in, pb)
   245  }
   246  
   247  // FileAndExt takes a path and returns the file and extension separated,
   248  // the extension including the delmiter, i.e. ".md".
   249  func FileAndExt(in string) (string, string) {
   250  	return fileAndExt(in, fpb)
   251  }
   252  
   253  // Filename takes a path, strips out the extension,
   254  // and returns the name of the file.
   255  func Filename(in string) (name string) {
   256  	name, _ = fileAndExt(in, fpb)
   257  	return
   258  }
   259  
   260  // FileAndExt returns the filename and any extension of a file path as
   261  // two separate strings.
   262  //
   263  // If the path, in, contains a directory name ending in a slash,
   264  // then both name and ext will be empty strings.
   265  //
   266  // If the path, in, is either the current directory, the parent
   267  // directory or the root directory, or an empty string,
   268  // then both name and ext will be empty strings.
   269  //
   270  // If the path, in, represents the path of a file without an extension,
   271  // then name will be the name of the file and ext will be an empty string.
   272  //
   273  // If the path, in, represents a filename with an extension,
   274  // then name will be the filename minus any extension - including the dot
   275  // and ext will contain the extension - minus the dot.
   276  func fileAndExt(in string, b filepathPathBridge) (name string, ext string) {
   277  	ext = b.Ext(in)
   278  	base := b.Base(in)
   279  
   280  	return extractFilename(in, ext, base, b.Separator()), ext
   281  }
   282  
   283  func extractFilename(in, ext, base, pathSeparator string) (name string) {
   284  
   285  	// No file name cases. These are defined as:
   286  	// 1. any "in" path that ends in a pathSeparator
   287  	// 2. any "base" consisting of just an pathSeparator
   288  	// 3. any "base" consisting of just an empty string
   289  	// 4. any "base" consisting of just the current directory i.e. "."
   290  	// 5. any "base" consisting of just the parent directory i.e. ".."
   291  	if (strings.LastIndex(in, pathSeparator) == len(in)-1) || base == "" || base == "." || base == ".." || base == pathSeparator {
   292  		name = "" // there is NO filename
   293  	} else if ext != "" { // there was an Extension
   294  		// return the filename minus the extension (and the ".")
   295  		name = base[:strings.LastIndex(base, ".")]
   296  	} else {
   297  		// no extension case so just return base, which willi
   298  		// be the filename
   299  		name = base
   300  	}
   301  	return
   302  
   303  }
   304  
   305  // GetRelativePath returns the relative path of a given path.
   306  func GetRelativePath(path, base string) (final string, err error) {
   307  	if filepath.IsAbs(path) && base == "" {
   308  		return "", errors.New("source: missing base directory")
   309  	}
   310  	name := filepath.Clean(path)
   311  	base = filepath.Clean(base)
   312  
   313  	name, err = filepath.Rel(base, name)
   314  	if err != nil {
   315  		return "", err
   316  	}
   317  
   318  	if strings.HasSuffix(filepath.FromSlash(path), FilePathSeparator) && !strings.HasSuffix(name, FilePathSeparator) {
   319  		name += FilePathSeparator
   320  	}
   321  	return name, nil
   322  }
   323  
   324  // PathPrep prepares the path using the uglify setting to create paths on
   325  // either the form /section/name/index.html or /section/name.html.
   326  func PathPrep(ugly bool, in string) string {
   327  	if ugly {
   328  		return Uglify(in)
   329  	}
   330  	return PrettifyPath(in)
   331  }
   332  
   333  // PrettifyPath is the same as PrettifyURLPath but for file paths.
   334  //     /section/name.html       becomes /section/name/index.html
   335  //     /section/name/           becomes /section/name/index.html
   336  //     /section/name/index.html becomes /section/name/index.html
   337  func PrettifyPath(in string) string {
   338  	return prettifyPath(in, fpb)
   339  }
   340  
   341  func prettifyPath(in string, b filepathPathBridge) string {
   342  	if filepath.Ext(in) == "" {
   343  		// /section/name/  -> /section/name/index.html
   344  		if len(in) < 2 {
   345  			return b.Separator()
   346  		}
   347  		return b.Join(in, "index.html")
   348  	}
   349  	name, ext := fileAndExt(in, b)
   350  	if name == "index" {
   351  		// /section/name/index.html -> /section/name/index.html
   352  		return b.Clean(in)
   353  	}
   354  	// /section/name.html -> /section/name/index.html
   355  	return b.Join(b.Dir(in), name, "index"+ext)
   356  }
   357  
   358  // ExtractRootPaths extracts the root paths from the supplied list of paths.
   359  // The resulting root path will not contain any file separators, but there
   360  // may be duplicates.
   361  // So "/content/section/" becomes "content"
   362  func ExtractRootPaths(paths []string) []string {
   363  	r := make([]string, len(paths))
   364  	for i, p := range paths {
   365  		root := filepath.ToSlash(p)
   366  		sections := strings.Split(root, "/")
   367  		for _, section := range sections {
   368  			if section != "" {
   369  				root = section
   370  				break
   371  			}
   372  		}
   373  		r[i] = root
   374  	}
   375  	return r
   376  
   377  }
   378  
   379  // FindCWD returns the current working directory from where the Hugo
   380  // executable is run.
   381  func FindCWD() (string, error) {
   382  	serverFile, err := filepath.Abs(os.Args[0])
   383  
   384  	if err != nil {
   385  		return "", fmt.Errorf("Can't get absolute path for executable: %v", err)
   386  	}
   387  
   388  	path := filepath.Dir(serverFile)
   389  	realFile, err := filepath.EvalSymlinks(serverFile)
   390  
   391  	if err != nil {
   392  		if _, err = os.Stat(serverFile + ".exe"); err == nil {
   393  			realFile = filepath.Clean(serverFile + ".exe")
   394  		}
   395  	}
   396  
   397  	if err == nil && realFile != serverFile {
   398  		path = filepath.Dir(realFile)
   399  	}
   400  
   401  	return path, nil
   402  }
   403  
   404  // SymbolicWalk is like filepath.Walk, but it supports the root being a
   405  // symbolic link. It will still not follow symbolic links deeper down in
   406  // the file structure.
   407  func SymbolicWalk(fs afero.Fs, root string, walker filepath.WalkFunc) error {
   408  
   409  	// Sanity check
   410  	if root != "" && len(root) < 4 {
   411  		return errors.New("Path is too short")
   412  	}
   413  
   414  	// Handle the root first
   415  	fileInfo, realPath, err := getRealFileInfo(fs, root)
   416  
   417  	if err != nil {
   418  		return walker(root, nil, err)
   419  	}
   420  
   421  	if !fileInfo.IsDir() {
   422  		return fmt.Errorf("Cannot walk regular file %s", root)
   423  	}
   424  
   425  	if err := walker(realPath, fileInfo, err); err != nil && err != filepath.SkipDir {
   426  		return err
   427  	}
   428  
   429  	// Some of Hugo's filesystems represents an ordered root folder, i.e. project first, then theme folders.
   430  	// Make sure that order is preserved. afero.Walk will sort the directories down in the file tree,
   431  	// but we don't care about that.
   432  	rootContent, err := readDir(fs, root, false)
   433  
   434  	if err != nil {
   435  		return walker(root, nil, err)
   436  	}
   437  
   438  	for _, fi := range rootContent {
   439  		if err := afero.Walk(fs, filepath.Join(root, fi.Name()), walker); err != nil {
   440  			return err
   441  		}
   442  	}
   443  
   444  	return nil
   445  
   446  }
   447  
   448  func readDir(fs afero.Fs, dirname string, doSort bool) ([]os.FileInfo, error) {
   449  	f, err := fs.Open(dirname)
   450  	if err != nil {
   451  		return nil, err
   452  	}
   453  	list, err := f.Readdir(-1)
   454  	f.Close()
   455  	if err != nil {
   456  		return nil, err
   457  	}
   458  	if doSort {
   459  		sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() })
   460  	}
   461  	return list, nil
   462  }
   463  
   464  func getRealFileInfo(fs afero.Fs, path string) (os.FileInfo, string, error) {
   465  	fileInfo, err := LstatIfPossible(fs, path)
   466  	realPath := path
   467  
   468  	if err != nil {
   469  		return nil, "", err
   470  	}
   471  
   472  	if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
   473  		link, err := filepath.EvalSymlinks(path)
   474  		if err != nil {
   475  			return nil, "", fmt.Errorf("Cannot read symbolic link '%s', error was: %s", path, err)
   476  		}
   477  		fileInfo, err = LstatIfPossible(fs, link)
   478  		if err != nil {
   479  			return nil, "", fmt.Errorf("Cannot stat '%s', error was: %s", link, err)
   480  		}
   481  		realPath = link
   482  	}
   483  	return fileInfo, realPath, nil
   484  }
   485  
   486  // GetRealPath returns the real file path for the given path, whether it is a
   487  // symlink or not.
   488  func GetRealPath(fs afero.Fs, path string) (string, error) {
   489  	_, realPath, err := getRealFileInfo(fs, path)
   490  
   491  	if err != nil {
   492  		return "", err
   493  	}
   494  
   495  	return realPath, nil
   496  }
   497  
   498  // LstatIfPossible can be used to call Lstat if possible, else Stat.
   499  func LstatIfPossible(fs afero.Fs, path string) (os.FileInfo, error) {
   500  	if lstater, ok := fs.(afero.Lstater); ok {
   501  		fi, _, err := lstater.LstatIfPossible(path)
   502  		return fi, err
   503  	}
   504  
   505  	return fs.Stat(path)
   506  }
   507  
   508  // SafeWriteToDisk is the same as WriteToDisk
   509  // but it also checks to see if file/directory already exists.
   510  func SafeWriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) {
   511  	return afero.SafeWriteReader(fs, inpath, r)
   512  }
   513  
   514  // WriteToDisk writes content to disk.
   515  func WriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) {
   516  	return afero.WriteReader(fs, inpath, r)
   517  }
   518  
   519  // OpenFileForWriting opens all the given filenames for writing.
   520  func OpenFilesForWriting(fs afero.Fs, filenames ...string) (io.WriteCloser, error) {
   521  	var writeClosers []io.WriteCloser
   522  	for _, filename := range filenames {
   523  		f, err := OpenFileForWriting(fs, filename)
   524  		if err != nil {
   525  			for _, wc := range writeClosers {
   526  				wc.Close()
   527  			}
   528  			return nil, err
   529  		}
   530  		writeClosers = append(writeClosers, f)
   531  	}
   532  
   533  	return hugio.NewMultiWriteCloser(writeClosers...), nil
   534  
   535  }
   536  
   537  // OpenFileForWriting opens or creates the given file. If the target directory
   538  // does not exist, it gets created.
   539  func OpenFileForWriting(fs afero.Fs, filename string) (afero.File, error) {
   540  	filename = filepath.Clean(filename)
   541  	// Create will truncate if file already exists.
   542  	f, err := fs.Create(filename)
   543  	if err != nil {
   544  		if !os.IsNotExist(err) {
   545  			return nil, err
   546  		}
   547  		if err = fs.MkdirAll(filepath.Dir(filename), 0755); err != nil {
   548  			return nil, err
   549  		}
   550  		f, err = fs.Create(filename)
   551  	}
   552  
   553  	return f, err
   554  }
   555  
   556  // GetTempDir returns a temporary directory with the given sub path.
   557  func GetTempDir(subPath string, fs afero.Fs) string {
   558  	return afero.GetTempDir(fs, subPath)
   559  }
   560  
   561  // DirExists checks if a path exists and is a directory.
   562  func DirExists(path string, fs afero.Fs) (bool, error) {
   563  	return afero.DirExists(fs, path)
   564  }
   565  
   566  // IsDir checks if a given path is a directory.
   567  func IsDir(path string, fs afero.Fs) (bool, error) {
   568  	return afero.IsDir(fs, path)
   569  }
   570  
   571  // IsEmpty checks if a given path is empty.
   572  func IsEmpty(path string, fs afero.Fs) (bool, error) {
   573  	return afero.IsEmpty(fs, path)
   574  }
   575  
   576  // FileContains checks if a file contains a specified string.
   577  func FileContains(filename string, subslice []byte, fs afero.Fs) (bool, error) {
   578  	return afero.FileContainsBytes(fs, filename, subslice)
   579  }
   580  
   581  // FileContainsAny checks if a file contains any of the specified strings.
   582  func FileContainsAny(filename string, subslices [][]byte, fs afero.Fs) (bool, error) {
   583  	return afero.FileContainsAnyBytes(fs, filename, subslices)
   584  }
   585  
   586  // Exists checks if a file or directory exists.
   587  func Exists(path string, fs afero.Fs) (bool, error) {
   588  	return afero.Exists(fs, path)
   589  }