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